# 线程

# 定义

  • 线程(Thread)是进程运行、CPU 调度的基本单位。

    • 每个进程至少包含一个线程,其中第一个创建的线程称为主线程(main thread)。
    • 启动一个程序时,至少要创建一个进程,来执行程序指令。但进程只是一个空空的线程组,其中的线程才是实体,决定了 CPU 实际执行的程序指令。
  • 进程与线程的主要区别:

    • 功能差不多,但创建线程的开销更小。因此线程相当于轻量级进程。
    • 同一进程中所有线程,会共享一些资源。
  • 每个线程可以创建任意个新线程。

    • 新线程属于当前线程的线程组,即属于同一个进程。
    • 进程之间可以存在父子关系,而线程之间不存在父子关系。
      • 同一进程中的所有线程,几乎相互平等。
      • 但主线程比其它线程更重要,有时将其它线程称为主线程的子线程。
  • 每个线程可以创建任意个新进程。不过此时看作是该线程所属的进程创建了新进程,考虑进程级别的概念,而不是线程级别。

# 占用资源

  • 一个进程分配的系统资源,大部分会被该进程下的各个线程共享。包括:

    • PID、PPID
    • User ID 、Group ID
    • 内存,包括:
      • 虚拟内存空间,寻址相同
      • 堆区,用于存储静态变量、全局变量等数据
    • 打开的文件描述符,比如 Socket
    • 环境变量,比如 PWD、HOSTNAME
    • 进程间通信的 signals 和 signal handlers
  • 每个线程有一些独立分配的系统资源。包括:

    • TID
    • CPU 调度,比如占用 CPU 时长、使用哪些寄存器
    • 栈区,用于存储局部变量等数据
  • 任一线程终止时,Linux 可以释放其线程级别的资源(比如栈区),但不能释放进程级别的资源(比如堆区),因为依然被其它线程使用。

    • 当一个进程中所有线程都终止时,Linux 才认为该进程已终止,释放该进程占用的系统资源。

# LWP

  • 上述的线程特点,是参考 POSIX 线程标准 (opens new window) ,简称为 pthread 。

    • 但 Linux 系统不完全遵循 POSIX 标准。因为早期的类 Unix 系统中,不存在线程的概念,只有进程的概念。
    • 直到目前,Linux 内核中的可调度实体依然是进程,又称为任务(task)。通常使用 LWP 来模拟线程。
  • 轻量级进程(Light Weight Process ,LWP)

    • 基本原理:在用户态创建一个看起来像线程的资源,与内核中的一个可调度实体关联,并且通常为 1 对 1 线程模型。
    • LWP 本质上是 Linux 进程,不是真正的 POSIX 线程。
      • Linux 创建进程、线程时,底层都是调用 clone() 来实现的。
      • 例如调用 pthread_create() 创建线程时,实际上会调用 clone() 创建一个子进程,并指定 CLONE_VM、CLONE_THREAD 等 flags ,使得子进程符合线程的特征。
    • 每个线程使用一个独立的 task_struct ,而不共享当前进程的 task_struct 。
      • 新建一个进程时,会在内存中新建一个 task_struct ,它既代表该进程,又代表主线程。
      • 主线程新建一个子线程时,会新建一个 task_struct ,它本质上属于进程,但跟主线程共享一些资源,因此能当作线程使用。
      • 主线程与子线程共享了很多资源,因此它们的 task_struct 大部分内容相同。比如 task->mm 指向同一个虚拟内存空间。
    • 每个线程会分配一个唯一的 task->pid ,当作 TID 使用。
      • 主线程的 task->pid ,既是该线程的 TID ,又是当前进程的 PID 。
      • 如果一个 task->pid 等于 task->tgid ,则说明这是一个主线程。否则,这是一个子线程。
      • 创建大量线程时,可能导致本机的 PID 可用数量不足。
    • 缺点:
      • 不完全遵循 POSIX 标准,用法有少许差异。
      • 与 POSIX 线程相比,创建 LWP 线程更慢、占用资源更多。
  • 假设某个 PID 的进程中包含多个不同 TID 的 LWP 线程,

    • 如果用 kill 命令向该 PID 发送信号,则会被主线程接收。因为主线程的 TID 等于 PID 。
      • 不过这是 LinuxThreads 的规则。而按照 POSIX 标准,向一个进程发送信号时,可能被其中任意一个线程接收。
    • 如果用 kill 命令向非主线程的 ITD 发送信号,则会被该线程接收。

# 底层实现

  • 1996 年,Linux 的 glibc 引入了 LinuxThreads 线程库,用于创建符合部分 POSIX 标准的线程。

    • 用法:调用 pthread.h 库的 pthread_create() 函数,创建 LWP 进程,用它模拟线程。
    • 缺点:
      • 创建一个进程时,首先会创建一个管理器线程(manager thread),它负责管理其它线程,增加了开销。
      • 同一进程中每个线程调用 getpid() 的返回值不同,会等于该线程的 PID ,而不是当前进程的 PID 。
  • 2005 年,Linux v2.6 的 glibc 引入了 NPTL(Native POSIX Thread Library)线程库,取代了 LinuxThreads ,用于创建更加符合 POSIX 标准的线程。

    • 优点:
      • 兼容 LinuxThreads 的 ABI 接口。因此可以将 pthread.h 库的底层实现改为 NPTL ,但用法不变,方便 Linux 旧版软件从 LinuxThreads 迁移到 NPTL 。
      • 解决了 LinuxThreads 的主要缺点。例如引入了线程组的概念,使得每个线程调用 getpid() 时会返回 TGID ,即当前进程的 PID 。
    • 缺点:
      • 依然采用 LWP 机制,不完全遵循 POSIX 标准。例如线程之间不共享 nice 值。

# pthread.h

#include <pthread.h>

int pthread_create(pthread_t *tid,              // 传入用于存储线程 TID 的指针变量
                   const pthread_attr_t *attr,  // 传入线程的属性(传入 NULL 则是默认属性)
                   void *(*)(void *),           // 传入要运行的函数,该函数头的格式应该定义成:void *fun(void *arg)
                   void *arg);                  // 传入要运行的函数的实参,没有参数则填 NULL
    // 用于创建一个线程,运行指定的函数
    // 如果创建成功,则将线程的 TID 存储到变量 tid 中,并返回 0

int pthread_join(pthread_t tid, void **retval);
    // 阻塞当前线程的执行,等待当前进程中指定 tid 的线程终止,然后才继续执行当前线程
    // 当目标线程终止时,会将其退出码存储到变量 retval 中

void pthread_exit(void *retval);
    // 使当前线程退出,且采用 retval 的值作为退出码

int pthread_cancel(pthread_t tid);
    // 向当前进程中指定 tid 的线程,发送 cancel 请求
    // 如果该函数的返回值为 0 ,则表示成功发送了 cancel 请求,但不知道目标线程会如何处理请求
    // 每个线程收到 cancel 请求时,默认会立即终止。但也可以忽略 cancel 请求,或者自定义一个处理 cancel 请求的函数

int pthread_kill(pthread_t tid, int sig);
    // 向当前进程中指定 tid 的线程,发送 sig 信号
    // 如果该函数的返回值为 0 ,则表示成功发送了信号,但不知道目标线程会如何处理信号
  • 编译时需要链接 pthread.h 库,例如:gcc test.c -o test -l pthread
  • 例:
    #include <stdio.h>
    #include <pthread.h>
    
    void *fun1(){
        puts("fun1() end.");
        return 0;
    }
    
    int main(){
        int rc;
        pthread_t tid;
    
        rc = pthread_create(&tid, NULL, fun1, NULL);
        if(rc)
            puts("Failed to create the thread fun1().");
    
        pthread_join(tid, NULL); // 阻塞主线程的运行。以免主线程执行完毕,提前终止子线程
        puts("main() end.");
        return 0;
    }
    

# 多线程

# 优点

  • 当一个程序需要让 CPU 执行多个任务时(比如执行多个函数),有几种策略:

    • 单线程:只创建一个线程,让 CPU 串行执行这些任务。
    • 多进程:创建多个进程,每个进程至少包含一个线程,并行执行。
    • 多线程:创建一个进程,包含多个线程,并行执行。
  • 多进程(multiprocess)的优点:

    • 每个进程使用的虚拟内存空间、物理内存等资源相互独立,不会相互影响。
    • 每个进程独立运行。一个进程挂掉了,通常不会影响其它进程。
  • 多线程(multithreading)的优点:

    • 与单线程相比,可以提高 CPU 的使用率。
      • 例如一个线程因为等待 IO 而暂停运行时,其它线程依然可以运行。
      • 例如一个线程占用了 CPU 的一个核心时,其它线程可以在 CPU 的其它核心上运行。
      • 处理 IO 密集型任务时,多线程、多进程都可以提高 CPU 的使用率,从而提高处理速度。
      • 处理 CPU 密集型任务时,多线程、多进程都不能提高处理速度。
    • 多线程相当于轻量级的多进程,开销更小。
      • 创建一个线程的耗时、资源开销,比进程小很多。
      • CPU 进程上下文切换的开销,大于 CPU 线程上下文切换。例如 CPU 原本执行进程 A (中的线程),想切换执行进程 B (中的线程),则需要切换进程使用的内存、堆栈等资源。
    • 与进程间通信相比,线程间通信更容易。
      • 同一个进程的多个线程之间,可以采用全局变量作为通信媒介,因为它们访问的是同一个堆区。

# 线程间通信

  • 线程间通信的频率:
    • 轮询(poll)
      • :线程 A 执行一个循环语句,反复获取线程 B 的数据,比如读取某个全局变量。
      • 优点:只要提高循环的频率,就可以实时获取线程 B 的数据。
      • 缺点:频繁轮询,会增加线程 A 的开销。比如占用更多 CPU 时长、不能长时间执行其它任务。
    • 回调(callback)
      • :线程 A 将自己某个函数的地址传给线程 B ,让线程 B 在必要时刻调用该函数,从而触发线程 A 的相关逻辑代码。
      • 该函数称为回调函数。
      • 优点:线程 A 是被动获取数据,不用频繁主动轮询,开销低。
      • 缺点:线程 B 不一定会及时调用该函数,导致线程 A 不能及时获取数据。

# 线程安全

  • 多进程、多线程可以并行工作,但可能出现以下问题:
    • 一个线程在访问当前进程的共享资源时,该资源被其它线程修改。
      • 对策:可以给该资源上锁,保证同时只能被一个线程访问。
    • 一个线程在 CPU 执行任务时,被其它线程打断、抢占 CPU 。
      • 对策:可以阻塞其它线程,先让该线程执行完任务。

# 线程终止

  • 终止线程的几种方式:

    • 如果一个线程调用 pthread_exit() ,则会使当前线程退出,不影响其它线程。
    • 如果一个线程的主函数执行到 return 语句,则考虑该线程的身份:
      • 如果该线程是普通线程,则会隐式调用 pthread_exit() ,使当前线程退出。
      • 如果该线程是主线程,则会隐式调用 exit() ,使整个进程退出。
        • 因此,创建多线程时,通常要用 pthread_join()、死循环等方式让主线程保持运行,避免子线程还没执行完毕就被终止。
    • 如果任一线程调用 exit() ,则会使整个进程退出。
      • 此时 Linux 会直接终止当前进程的所有线程,不会发送 SIGTERM 等信号。
    • 一个线程调用 pthread_cancel() ,向其它线程发送 cancel 请求。
    • 一个线程调用 pthread_kill() ,向其它线程发送终止信号。
  • 终止线程时,哪个方式更好?

    • 不应该采用 pthread_cancel() 或 pthread_kill() ,因为线程收到这种信号时,默认会立即退出,导致程序代码还没执行完毕,例如尚未将内存中的数据持久化到磁盘。
    • 推荐方案:通过线程间通信,通知该线程,请它自行终止。它收到通知之后,应该做好收尾工作(例如持久化内存中的数据),然后执行 pthread_exit() 或 return 语句。
    • 同理,终止一个进程时,通常是用 kill() 发送 SIGTERM 信号,因为大部分进程都会捕捉该信号,触发正常终止的逻辑代码。
      • 如果用 kill() 发送 SIGKILL 信号,则会立即杀死进程,容易导致进程异常终止。
  • 终止线程之后,何时释放它占用的资源?有两种策略:

    • joinable
      • :默认策略。当线程终止时,不立即释放它占用的资源,而是等其它线程调用 pthread_join() 来获取该线程的退出码时,才释放资源。
    • detached
      • :当线程终止时,立即释放它占用的资源。
      • 优点:避免出现僵尸线程。
      • 缺点:其它线程不知道该线程的退出时刻、退出码。