31、多线程编程全解析:从基础到调度

多线程编程全解析:从基础到调度

1. 多线程编程基础

多线程编程在现代软件开发中扮演着至关重要的角色,它允许程序同时执行多个任务,提高了程序的性能和响应能力。在 Linux 系统中,POSIX 线程 API(pthreads)是实现多线程编程的标准接口,它最早在 IEEE POSIX 1003.1c 标准(1995 年)中被定义,并作为 C 库的一部分(libpthread.so)实现。

过去 15 年左右,pthreads 有两种实现:LinuxThreads 和 Native POSIX Thread Library(NPTL)。NPTL 更符合规范,特别是在信号处理和进程 ID 处理方面,现在它占据主导地位,但在一些旧版本的 uClibc 中仍可能使用 LinuxThreads。

2. 创建新线程

创建线程的函数是 pthread_create(3) ,其原型如下:

int pthread_create(pthread_t *thread, const pthread_attr_t *attr, 
   void *(*start_routine) (void *), void *arg);

该函数创建一个新的执行线程,线程从 start_routine 函数开始执行,并将线程描述符存储在 thread 指向的 pthread_t 变量中。新线程继承调用线程的调度参数,但可以通过 attr 指针传递线程属性来覆盖这些参数。线程创建后将立即开始执行。

pthread_t 是在程序中引用线程的主要方式,也可以使用 ps -eLf 命令从外部查看线程信息,例如:

UID    PID  PPID   LWP  C  NLWP  STIME        TTY           TIME CMD
...
chris  6072  5648  6072  0   3    21:18  pts/0 00:00:00 ./thread-demo
chris  6072  5648  6073  0   3    21:18  pts/0 00:00:00 ./thread-demo  

在上述输出中, thread-demo 程序有两个线程。 PID PPID 列表明它们属于同一个进程且有相同的父进程。 LWP 列代表轻量级进程,也就是线程,其中的数字也称为线程 ID(TID)。主线程的 TID 与 PID 相同,其他线程的 TID 是不同(更高)的值。在某些文档要求提供 PID 的地方可以使用 TID,但要注意这种行为是 Linux 特有的,不具有可移植性。

以下是一个简单的程序,展示了线程的生命周期:

#include <stdio.h> 
#include <unistd.h> 
#include <pthread.h> 
#include <sys/syscall.h> 

static void *thread_fn(void *arg) 
{ 
  printf("New thread started, PID %d TID %d\n", 
         getpid(), (pid_t)syscall(SYS_gettid)); 
  sleep(10); 
  printf("New thread terminating\n"); 
  return NULL; 
} 


int main(void)
{ 
  pthread_t t; 
  printf("Main thread, PID %d TID %d\n", 
         getpid(), (pid_t)syscall(SYS_gettid)); 
  pthread_create(&t, NULL, thread_fn, NULL); 
  pthread_join(t, NULL); 
  return 0; 
} 

thread_fn 函数中,使用 syscall(SYS_gettid) 来获取 TID,因为没有 C 库包装器,所以需要直接通过系统调用。

每个内核能够调度的线程总数是有限的,该限制根据系统大小而定,小型设备约为 1000 个,大型嵌入式设备可达数万个。实际数量可以在 /proc/sys/kernel/threads-max 文件中查看。一旦达到这个限制, fork pthread_create 函数将失败。

3. 线程终止

线程在以下情况下终止:
- 到达 start_routine 函数的末尾。
- 调用 pthread_exit(3)
- 被其他线程调用 pthread_cancel(3) 取消。
- 包含该线程的进程终止,例如线程调用 exit(3) ,或者进程收到未处理、未屏蔽或未忽略的信号。

需要注意的是,如果多线程程序调用 fork ,只有调用该函数的线程会存在于新的子进程中, fork 不会复制所有线程。

线程有一个返回值,是一个 void 指针。一个线程可以通过调用 pthread_join(2) 等待另一个线程终止并收集其返回值。如果线程未被 join ,会导致资源泄漏,类似于进程中的僵尸问题。

4. 编译多线程程序

POSIX 线程支持是 C 库中 libpthread.so 库的一部分。但构建多线程程序不仅仅是链接该库,编译器生成代码的方式也需要改变,以确保某些全局变量(如 errno )每个线程都有一个实例,而不是整个进程共享一个实例。

在编译和链接多线程程序时,必须添加 -pthread 开关。

5. 线程间通信

线程的主要优点是它们共享地址空间和内存变量,但这也是一个缺点,因为需要同步来保证数据一致性,类似于进程间共享内存段,但线程共享所有内存。实际上,线程可以使用线程局部存储(TLS)创建私有内存,但这里不做详细介绍。

pthreads 接口提供了实现同步所需的基本机制:互斥锁(mutexes)和条件变量(condition variables)。如果需要更复杂的结构,则需要自己实现。

值得注意的是,之前介绍的所有进程间通信(IPC)方法,如套接字、管道和消息队列,在同一进程的线程之间同样适用。

6. 互斥锁

为了编写健壮的程序,需要使用互斥锁保护每个共享资源,并确保每个读写该资源的代码路径先锁定互斥锁。如果始终遵循这个规则,大多数问题应该可以解决。但仍然存在一些与互斥锁基本行为相关的问题,简要介绍如下:
- 死锁 :当互斥锁永久锁定时会发生死锁。典型情况是“死锁拥抱”,即两个线程都需要两个互斥锁,并且各自锁定了其中一个,但无法锁定另一个。每个线程都会阻塞,等待对方持有的锁,从而陷入僵局。避免死锁拥抱问题的一个简单规则是确保互斥锁始终以相同的顺序锁定,其他解决方案包括设置超时和回退期。
- 优先级反转 :等待互斥锁导致的延迟可能会使实时线程错过截止时间。具体来说,当高优先级线程因等待低优先级线程锁定的互斥锁而被阻塞时,就会发生优先级反转。如果低优先级线程被中等优先级线程抢占,高优先级线程将被迫无限期等待。有一些互斥锁协议,如优先级继承和优先级上限,可以解决这个问题,但每次锁定和解锁调用都会增加内核的处理开销。
- 性能不佳 :只要线程大部分时间不需要阻塞在互斥锁上,互斥锁对代码的开销就很小。但如果设计中有很多线程需要访问同一个资源,竞争比例会变得很显著,这通常是一个设计问题,可以通过更细粒度的锁定或不同的算法来解决。

7. 条件变量

协作线程需要一种方法来相互通知某些事情发生了变化,需要关注。这种事情称为条件,通知通过条件变量(condvar)发送。

条件是可以测试并返回布尔结果的东西。例如,一个缓冲区可能为空或包含一些项。一个线程从缓冲区中取项,当缓冲区为空时休眠;另一个线程向缓冲区中放入项,并在放入后通知另一个线程,因为另一个线程等待的条件发生了变化。如果该线程正在休眠,它需要唤醒并执行相应操作。唯一的复杂性在于条件是一个共享资源,因此必须用互斥锁保护。

以下是一个包含两个线程的简单程序,一个是生产者,另一个是消费者:

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <string.h>
char g_data[128];
pthread_cond_t cv = PTHREAD_COND_INITIALIZER;
pthread_mutex_t mutx = PTHREAD_MUTEX_INITIALIZER;
void *consumer(void *arg) 
{
   while (1) {
    pthread_mutex_lock(&mutx);
    while (strlen(g_data) == 0)
      pthread_cond_wait(&cv, &mutx);
    /* Got data */
    printf("%s\n", g_data);
    /* Truncate to null string again */
    g_data[0] = 0;
    pthread_mutex_unlock(&mutx);
    }
  return NULL;
}

void *producer(void *arg)
{
  int i = 0;
  while (1) {
    sleep(1);
    pthread_mutex_lock(&mutx);
    sprintf(g_data, "Data item %d", i);
    pthread_mutex_unlock(&mutx);
    pthread_cond_signal(&cv);
    i++;
    }
  return NULL;
} 

当消费者线程在条件变量上阻塞时,它持有一个锁定的互斥锁,这看起来可能会导致生产者线程下次更新条件时发生死锁。为了避免这种情况, pthread_cond_wait(3) 在线程阻塞后解锁互斥锁,并在唤醒线程并返回等待之前再次锁定它。

8. 问题划分

在了解了进程和线程的基础知识以及它们的通信方式后,接下来看看如何利用它们。以下是构建系统时可以遵循的一些规则:
1. 保持交互频繁的任务在一起 :将紧密协作的线程放在一个进程中,以最小化开销。
2. 不要把所有线程放在一个进程中 :将交互较少的组件放在不同的进程中,以提高系统的弹性和模块化。
3. 不要在同一进程中混合关键和非关键线程 :系统的关键部分(如机器控制程序)应尽可能简单,并以更严格的方式编写。即使其他进程失败,关键部分也必须能够继续运行。如果有实时线程,它们必须是关键的,应单独放在一个进程中。
4. 线程不要过于紧密耦合 :编写多线程程序时,不要因为代码和变量都在一个程序中就轻易混合线程之间的代码和变量。保持线程的模块化,有明确的交互。
5. 不要认为创建线程没有成本 :创建额外的线程很容易,但会有成本,特别是在协调它们的活动时需要额外的同步。
6. 线程可以并行工作 :线程可以在多核处理器上同时运行,提高吞吐量。如果有大型计算任务,可以为每个核心创建一个线程,充分利用硬件资源。有一些库(如 OpenMP)可以帮助实现这一点,通常不建议从头开始编写并行编程算法。

Android 设计就是一个很好的例子。每个应用程序都是一个单独的 Linux 进程,有助于模块化内存管理,并确保一个应用程序崩溃不会影响整个系统。进程模型还用于访问控制,一个进程只能访问其 UID 和 GIDs 允许的文件和资源。每个进程中有一组线程,包括管理和更新用户界面的线程、处理操作系统信号的线程、管理动态内存分配和释放 Java 对象的线程,以及至少两个使用 Binder 协议从系统其他部分接收消息的工作线程池。

进程提供了弹性,因为每个进程都有受保护的内存空间,进程终止时会释放所有资源(包括内存和文件描述符),减少资源泄漏。而线程共享资源,可以通过共享变量轻松通信,并通过共享对文件和其他资源的访问进行协作。线程通过工作线程池和其他抽象实现并行性,在多核处理器中非常有用。

9. 调度

调度是多线程编程中的另一个重要话题。Linux 调度器有一个准备运行的线程队列,其工作是在 CPU 可用时调度这些线程。每个线程有一个调度策略,可以是分时共享或实时的。分时共享线程有一个 nice 值,用于增加或减少其对 CPU 时间的占用。实时线程有一个优先级,高优先级线程会抢占低优先级线程。调度器是针对线程进行调度的,而不是进程,每个线程的调度与它所在的进程无关。

调度器在以下情况下运行:
- 线程调用 sleep() 或其他阻塞系统调用而被阻塞。
- 分时共享线程耗尽其时间片。
- 中断导致线程解除阻塞,例如 I/O 完成。

10. 公平性与确定性

调度策略可以分为分时共享和实时两类。分时共享策略基于公平原则,旨在确保每个线程获得公平的处理器时间,防止某个线程独占系统。如果一个线程运行时间过长,它会被放到队列末尾,让其他线程有机会运行。同时,公平策略需要根据线程的工作量进行调整,为工作量大的线程提供足够的资源。分时共享调度的优点是能够自动适应各种工作负载。

对于实时程序,公平性并不是很有用,而是需要确定性的调度策略,以确保实时线程能够在正确的时间被调度,不会错过截止时间。这意味着实时线程必须能够抢占分时共享线程。实时线程还有一个静态优先级,调度器可以根据这个优先级在多个实时线程中进行选择。Linux 实时调度器实现了一个相当标准的算法,即运行优先级最高的实时线程。大多数实时操作系统(RTOS)调度器也是这样实现的。

两种类型的线程可以共存,需要确定性调度的线程先被调度,剩余的时间再分配给分时共享线程。

11. 分时共享策略

分时共享策略旨在实现公平性。从 Linux 2.6.23 开始,使用的调度器是完全公平调度器(CFS)。它不使用传统意义上的时间片,而是计算线程如果获得公平的 CPU 时间应该运行的时间,并将其与实际运行时间进行平衡。如果线程超过了其应得的时间,并且有其他分时共享线程等待运行,调度器将暂停该线程,转而运行等待的线程。

分时共享策略有以下几种:
- SCHED_NORMAL(也称为 SCHED_OTHER) :这是默认策略,大多数 Linux 线程使用此策略。
- SCHED_BATCH :与 SCHED_NORMAL 类似,但线程调度的粒度更大,即运行时间更长,但再次调度的等待时间也更长。目的是减少后台处理(批处理作业)的上下文切换次数,减少 CPU 缓存的颠簸。
- SCHED_IDLE :只有在没有其他策略的线程准备运行时,这些线程才会运行,是最低优先级的策略。

有两对函数可以获取和设置线程的策略和优先级:
第一对函数以 PID 为参数,影响进程中的主线程:

struct sched_param { 
  ... 
  int sched_priority; 
  ... 
};
int sched_setscheduler(pid_t pid, int policy, 
                       const struct sched_param *param);
int sched_getscheduler(pid_t pid); 

第二对函数以 pthread_t 为参数,可以更改进程中其他线程的参数:

int pthread_setschedparam(pthread_t thread, int policy,
                          const struct sched_param *param);

int pthread_getschedparam(pthread_t thread, int *policy,
                          struct sched_param *param);
12. nice

一些分时共享线程比其他线程更重要,可以通过 nice 值来表示。 nice 值将线程的 CPU 占用时间乘以一个缩放因子。这个名称来自 nice(2) 函数调用,它从早期的 Unix 就开始存在。线程通过减少对系统的负载变得“友好”,或者通过增加负载变得“不友好”。 nice 值的范围从 19(非常友好)到 -20(非常不友好),默认值是 0(中等友好)。

nice 值可以为 SCHED_NORMAL SCHED_BATCH 线程更改。要降低 nice 值(增加 CPU 负载),需要 CAP_SYS_NICE 能力,只有 root 用户才有此能力。

几乎所有关于更改 nice 值的函数和命令(如 nice(2) nice renice 命令)的文档都以进程为单位进行描述,但实际上它与线程相关。可以使用 TID 代替 PID 来更改单个线程的 nice 值。

标准描述中关于 nice 值的一个差异是, nice 值有时被称为线程(或错误地称为进程)的优先级,这容易与实时优先级混淆,实际上它们是完全不同的概念。

综上所述,多线程编程涉及多个方面,包括线程的创建、终止、通信、调度等。了解这些知识对于编写高效、健壮的多线程程序至关重要。在实际应用中,需要根据具体需求选择合适的调度策略和同步机制,同时注意线程的成本和资源管理,以充分发挥多线程编程的优势。

多线程编程全解析:从基础到调度

13. 调度策略总结

为了更清晰地了解不同调度策略的特点,下面通过表格形式进行总结:
| 调度策略 | 描述 | 适用场景 |
| ---- | ---- | ---- |
| SCHED_NORMAL(SCHED_OTHER) | 默认策略,大多数 Linux 线程使用 | 普通的用户进程和线程 |
| SCHED_BATCH | 调度粒度大,运行时间长,等待时间长 | 后台批处理作业 |
| SCHED_IDLE | 只有在无其他策略线程就绪时运行,最低优先级 | 系统空闲时执行的低优先级任务 |
| 实时调度策略 | 高优先级线程可抢占低优先级线程,有静态优先级 | 对时间确定性要求高的实时任务 |

从这个表格可以看出,不同的调度策略适用于不同的场景,开发者需要根据任务的特点来选择合适的策略。

14. 线程调度流程图

下面通过 mermaid 格式的流程图来展示线程调度的基本流程:

graph TD;
    A[线程就绪队列] --> B{CPU 是否可用};
    B -- 是 --> C{线程调度策略};
    C -- 分时共享 --> D[根据 nice 值和 CFS 算法调度];
    C -- 实时 --> E[根据优先级调度];
    D --> F[线程执行];
    E --> F;
    F --> G{线程状态改变};
    G -- 阻塞 --> H[线程进入阻塞队列];
    G -- 时间片耗尽 --> A;
    H --> I{阻塞条件满足};
    I -- 是 --> A;

这个流程图展示了线程从就绪队列到执行,再到可能的阻塞和重新就绪的整个过程,清晰地体现了不同调度策略在线程调度中的应用。

15. 多线程编程的性能优化

在多线程编程中,性能优化是一个重要的目标。以下是一些具体的优化建议:
- 合理使用互斥锁 :互斥锁虽然能保证数据一致性,但使用不当会导致性能问题。尽量减少锁的持有时间,使用细粒度的锁,避免多个线程长时间竞争同一把锁。例如,在前面的生产者 - 消费者示例中,生产者和消费者线程在操作共享缓冲区时,只在必要的代码段加锁,减少锁的影响范围。
- 优化线程数量 :线程数量过多会增加上下文切换的开销,而过少则无法充分利用多核处理器的性能。根据系统的核心数和任务的特点,合理确定线程数量。对于计算密集型任务,线程数量可以接近或等于核心数;对于 I/O 密集型任务,可以适当增加线程数量。
- 使用线程池 :线程池可以避免频繁创建和销毁线程的开销。当有任务到来时,从线程池中获取空闲线程执行任务,任务完成后线程返回线程池。许多编程语言和框架都提供了线程池的实现,如 Java 中的 ExecutorService

16. 多线程编程的错误处理

多线程编程中容易出现各种错误,以下是一些常见错误及处理方法:
- 死锁处理 :死锁是多线程编程中常见的问题。为了避免死锁,可以遵循前面提到的规则,如按相同顺序锁定互斥锁、设置超时等。当发现死锁时,可以使用工具(如 gdb )来调试,找出死锁的线程和锁。
- 数据竞争 :多个线程同时访问和修改共享数据可能导致数据竞争。使用互斥锁、信号量等同步机制来保护共享数据,确保同一时间只有一个线程可以访问。
- 异常处理 :在多线程程序中,一个线程抛出的异常可能会影响其他线程。为每个线程设置异常处理机制,捕获并处理异常,避免异常导致整个程序崩溃。

17. 多线程编程的实际应用案例

多线程编程在许多领域都有广泛的应用,以下是一些实际案例:
- 服务器端编程 :在 Web 服务器中,使用多线程可以同时处理多个客户端请求,提高服务器的并发处理能力。例如,Apache 服务器可以使用多线程模块来处理并发请求。
- 图形处理 :在图形渲染和图像处理中,多线程可以加速计算过程。例如,在游戏开发中,使用多线程来处理不同的渲染任务,如场景渲染、角色动画等。
- 数据处理 :在大数据处理中,多线程可以并行处理数据,提高处理速度。例如,在数据挖掘和机器学习中,使用多线程来并行计算模型的参数。

18. 多线程编程的未来发展趋势

随着硬件技术的不断发展,多核处理器越来越普及,多线程编程将变得更加重要。未来,多线程编程可能会朝着以下方向发展:
- 更高级的并行编程模型 :除了现有的线程和进程模型,可能会出现更高级的并行编程模型,如任务并行、数据并行等,以更方便地利用多核处理器的性能。
- 自动并行化 :编译器和运行时系统可能会自动将串行代码转换为并行代码,减少开发者的编程难度。
- 人工智能与多线程的结合 :在人工智能领域,多线程编程可以用于加速模型训练和推理过程。未来,可能会出现更多专门针对人工智能的多线程编程库和框架。

19. 总结

多线程编程是现代软件开发中不可或缺的一部分,它可以提高程序的性能和响应能力。通过本文的介绍,我们了解了多线程编程的基础知识,包括线程的创建、终止、通信、调度等,以及如何处理多线程编程中的常见问题和进行性能优化。

在实际应用中,需要根据具体需求选择合适的调度策略和同步机制,合理划分问题,避免常见的错误。同时,随着技术的不断发展,多线程编程也将不断演进,为开发者带来更多的机遇和挑战。希望本文能帮助读者更好地掌握多线程编程的技巧,编写出高效、健壮的多线程程序。

以下是一个简单的多线程性能测试程序示例,用于比较单线程和多线程在计算密集型任务中的性能:

#include <stdio.h>
#include <pthread.h>
#include <time.h>

#define NUM_THREADS 4
#define ITERATIONS 1000000000

// 单线程计算函数
void single_thread_computation() {
    long long sum = 0;
    for (long long i = 0; i < ITERATIONS; i++) {
        sum += i;
    }
    printf("Single thread sum: %lld\n", sum);
}

// 多线程计算函数
void *thread_computation(void *arg) {
    long long start = (long long)arg;
    long long end = start + ITERATIONS / NUM_THREADS;
    long long sum = 0;
    for (long long i = start; i < end; i++) {
        sum += i;
    }
    return (void *)sum;
}

int main() {
    clock_t start, end;
    double cpu_time_used;

    // 单线程测试
    start = clock();
    single_thread_computation();
    end = clock();
    cpu_time_used = ((double) (end - start)) / CLOCKS_PER_SEC;
    printf("Single thread time: %f seconds\n", cpu_time_used);

    // 多线程测试
    pthread_t threads[NUM_THREADS];
    long long results[NUM_THREADS];
    start = clock();
    for (long long i = 0; i < NUM_THREADS; i++) {
        pthread_create(&threads[i], NULL, thread_computation, (void *)(i * (ITERATIONS / NUM_THREADS)));
    }
    long long total_sum = 0;
    for (long long i = 0; i < NUM_THREADS; i++) {
        pthread_join(threads[i], (void **)&results[i]);
        total_sum += results[i];
    }
    printf("Multi-thread sum: %lld\n", total_sum);
    end = clock();
    cpu_time_used = ((double) (end - start)) / CLOCKS_PER_SEC;
    printf("Multi-thread time: %f seconds\n", cpu_time_used);

    return 0;
}

这个程序通过比较单线程和多线程在计算大量整数和时的时间消耗,展示了多线程在计算密集型任务中的性能优势。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值