【操作系统概念读书笔记】线程基础知识

Thursday, April 13, 2023
本文共6963字
14分钟阅读时长

⚠️本文是作者P3troL1er原创,首发于https://peterliuzhi.top/principle/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F%E6%A6%82%E5%BF%B5%E8%AF%BB%E4%B9%A6%E7%AC%94%E8%AE%B0%E7%BA%BF%E7%A8%8B%E5%9F%BA%E7%A1%80%E7%9F%A5%E8%AF%86/。商业转载请联系作者获得授权,非商业转载请注明出处!

Applause is a receipt, not a bill. — Dale Carnegie

什么是线程

A thread is a basic unit of CPU utilization

每个线程是一个CPU使用的基本单元,包括线程ID、程序计数器(Program Counter,PC,和CS:IP差不多)、寄存器组、堆栈(stack)等

在大多数现代操作系统中,线程是内核调度的最小单位。

需要注意的是,传统的线程概念是由Windows发展的,在Linux中原生没有进程与线程的明确区分,只有任务(task),进程和线程都是任务的子概念

在Linux中,进程和线程都是使用task_struct结构体来表示的。每个进程都有一个唯一的进程ID(PID),而每个线程都有一个唯一的线程ID(TID)。在Linux中,线程也被称为轻量级进程(Lightweight Process,LWP),它们与普通进程共享某些资源,如虚拟内存空间、文件描述符和信号处理程序等。

文内图片

多线程的优点

  • 响应性,可以同时处理多个任务,减少等待时间
  • 资源共享,线程相较于需要特别设计的进程,是默认共享内存和资源的
  • 经济,创建进程需要的资源比线程更多
  • 可伸缩性,多线程进程可以用于多个CPU,而单线程进程只能用于一个CPU

多核编程

并发和并行

  • 并发性(concurrency)要求支持多个任务,并且每个任务都可以取得进展
  • 并行性(parallelism)要求可以同时执行多个任务

显然,并发性可以在没有实现并行性的时候实现

两种并行类型

  • 数据并行
    • 注重将数据分布于多个计算核上,每个线程执行相同的操作
  • 任务并行
    • 注重将任务分布于多个计算核上,每个线程执行不同的操作

文内图片

多线程模型

用户线程和内核线程

  • 用户线程处于用户层,在内核层之上
    • 轻量级:管理无需内核支持,操作系统不知道它,创建和切换的代价小,从而通过快速切换任务可以实现更高的并发性
    • 无法利用多核CPU的并行性,也无法直接调用操作系统提供的系统资源和服务(例如文件I/O、网络通信等),必须通过系统调用的方式才能与操作系统内核交互
  • 内核线程直接由操作系统支持与管理
    • 能够直接访问操作系统内核的资源和服务,可以利用多核CPU的并行性,具有更高的可靠性和安全性
    • 创建和切换的代价较大,也会占用更多的系统资源

为了兼顾用户线程和内核线程的优点,常常采用一种折中的解决方案,即将用户线程映射到内核线程上。这样,用户线程就能够利用操作系统内核的资源和服务,同时也能够充分利用多核CPU的并行性。

下面就来看一看这些将用户线程映射到内核线程的模型

多对一模型

文内图片

多个用户线程映射到一个内核线程,理论上可以实现任意多个线程,但是:

  • 这样多线程就不能运行于多个处理核上,因此并行性没有得到实现
  • 一个线程堵塞会导致整个进程堵塞

一对一模型

文内图片

允许一个用户线程映射到一个内核线程,允许多个线程运行在多个处理核上,但是这样就限制了线程的数量,不能创建过多的线程

多对多模型

文内图片

多路复用多个用户线程到相同数量或更少的内核线程(取决于具体应用程序或机器),用户线程的数量理论上是无限的

需要注意的是,因为内核线程是固定的,所以增加用户线程数量并不会增加并发性。

因为用户线程映射到多个内核线程,所以一个线程的阻塞不会导致整个进程的阻塞

虽然多对多模型很灵活,但是因为多对多模型比较复杂,又因为CPU核数越来越多,所以现在越来越趋向于使用一对一模型(大部分系统现在都使用一对一模型)

双层模型

文内图片

结合了多对多和一对一

Linux下的多线程编程

系统调用fork和exec

有两个问题:

  • fork会复制一个进程的所有线程还是单个线程?
    • 有的UNIX系统有两种fork,既可以复制所有线程,又可以复制单个线程
  • exec会覆盖一个进程的所有线程还是单个线程?
    • exec会覆盖所有线程

Linux下如何创建线程

因为Linux下理论上没有进程与线程之分,只有task,因此创建进程和创建线程之前的区别非常微妙,没有非常明确的界限(不代表没有界限)

具体地,Linux下创建进程可以使用clone系统调用,也可以使用pthread库来创建和管理线程

如果使用clone的话,代码是比较原始的,因为线程之间默认共享内存,所以程序需要划分一块栈空间给新线程,并保证其不会越界:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sched.h>

#define STACK_SIZE 65536

void *thread_func(void *arg) {
    printf("Hello, world!\n");
    return NULL;
}

int main() {
    char *stack = malloc(STACK_SIZE);
    int tid = clone(thread_func, stack + STACK_SIZE,
                    CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, NULL);
    if (tid == -1) {
        perror("clone");
        exit(EXIT_FAILURE);
    }
    printf("New thread ID: %d\n", tid);
    sleep(1);
    return 0;
}

其中clone()系统调用的函数原型是:

int clone(int (*fn)(void *), void *child_stack,
          int flags, void *arg, ...
          /* pid_t *ptid, struct user_desc *tls, pid_t *ctid */ );

其中,fn 参数是一个函数指针,用于指定新线程的入口函数,child_stack 参数是新线程的栈空间,flags 参数用于指定新线程的标志,arg 参数是传递给新线程入口函数的参数。clone() 调用成功时返回新线程的线程 ID(TID)。

重要的是其中的flags参数,一般来说需要将CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND四个flag或起来作为flags参数传递(内存空间共享、文件系统信息共享、打开文件集共享、信号处理程序共享),如果不指定任何flags,那么就不会产生任何共享,效果类似于用fork新建进程

如果指定CLONE_THREAD标识,则新建线程会与主线程具有同一线程组ID,属于同一进程

如果你在调用clone函数时指定了CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND这四个标志,但没有指定CLONE_THREAD标志,那么新创建的线程将不会是一个从属线程,而是一个独立的进程。

当你指定了CLONE_VM标志时,新创建的进程将与调用进程共享虚拟内存空间。当你指定了CLONE_FS标志时,新创建的进程将与调用进程共享文件系统信息,如根目录、当前工作目录和umask值等。当你指定了CLONE_FILES标志时,新创建的进程将与调用进程共享文件描述符表。当你指定了CLONE_SIGHAND标志时,新创建的进程将与调用进程共享信号处理程序。

然而,即使你指定了这些标志,如果你没有指定CLONE_THREAD标志,那么新创建的线程仍然会被视为一个独立的进程。它将拥有自己的进程ID和线程组ID,并且不会与调用进程属于同一线程组。

文内图片

具体区别如下:

  1. clone() 可以控制新进程或线程的栈空间。clone() 系统调用可以通过设置新进程或线程的栈空间的起始地址和大小,从而灵活地控制栈空间的大小和使用方式。
  2. clone() 可以控制新进程或线程的执行环境。clone() 系统调用可以通过设置新进程或线程的执行环境,如信号屏蔽字、CPU 亲和性等,从而灵活地控制新进程或线程的执行环境。
  3. clone() 可以创建更轻量级的线程。由于 clone() 可以创建共享资源的进程或线程,因此可以使用 clone() 调用创建更轻量级的线程,而不需要像 fork() 调用那样创建一个全新的进程。

在 clone() 系统调用中,如果不设置 CLONE_VM 标识,则新线程将使用主线程分配的内存作为自己的栈空间的缓冲区。具体来说,主线程会为新线程分配一块内存,作为新线程栈空间的缓冲区,但是这块内存并不是直接作为新线程的栈空间使用的,而是在新线程启动时被映射到新线程的栈空间中。

这种方式被称为栈空间的缓冲区或栈空间的预留区。它的作用是提高程序的性能,减少频繁的系统调用和内存分配操作。在新线程启动时,它可以快速地将栈空间的缓冲区映射到新线程的栈空间中,避免了频繁的内存分配操作,从而提高了程序的运行效率。

需要注意的是,栈空间的缓冲区并不是新线程的实际栈空间,它只是一个临时的缓冲区,用于存储新线程栈空间的初始状态。一旦新线程启动后,它的栈空间将从栈空间的缓冲区中复制出来,并成为新线程的实际栈空间,从此以后,新线程的栈空间将与主线程的栈空间是完全独立的,互相之间不会产生任何影响。

如果使用pthread库,就要先包含pthread.h头文件

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

void *thread_func(void *arg) {
    printf("Hello, world!\n");
    pthread_exit(NULL);
}

int main() {
    pthread_t tid;
    pthread_create(&tid, NULL, thread_func, NULL);
    pthread_join(tid, NULL);
    return 0;
}

其中:

  1. 包含头文件 pthread.h。
  2. 定义线程函数,函数返回类型为 void *,参数为 void *。
  3. 在主函数中使用 pthread_create 函数来创建线程,该函数的参数包括线程标识符、线程属性、线程函数和函数参数。
  4. 使用 pthread_join 函数来等待线程结束并获取线程返回值。
  5. 使用 pthread_exit 函数来结束当前线程。

UNIX信号与其处理

什么是信号?

  • 信号是由特定事件的发生而产生的(比如按下Ctrl-C)
  • 信号需要被传递给某个进程
  • 信号一经收到就必须处理

如何处理信号?

  • 使用缺省的信号处理程序(由内核运行)
  • 使用用户自定义的信号处理程序

而因为信号传递给进程,所以多线程进程需要选择如何传递信号。有以下几种策略:

  • 传递信号到信号所适用的线程
  • 传递信号到所有线程
  • 传递信号到某些线程

在Linux中,可以通过线程组ID(Thread Group ID,TGID)来定义一个进程的所有线程。线程组ID实际上就是该进程主线程的线程ID。也就是说,一个进程的所有线程都具有相同的线程组ID。因此,你可以通过检查线程的线程组ID来确定它们是否属于同一进程。

在Windows下并不原生有信号支持,但是可以通过异步过程调用(Asychronous Procedure Call, APC)来指定当线程收到某特定通知的时候调用的函数。因为Windows中是将APC传递给线程而非进程,所以相对于UNIX更加简单。

标准的信号处理函数如下,向指定进程传递信号。而得到信号的进程需要自己决定如何处理该信号。

#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);

其中,pid参数指定了接收信号的进程或进程组。当pid大于0时,信号将被发送给进程ID为pid的进程。当pid等于0时,信号将被发送给与调用进程属于同一进程组的所有进程。当pid小于-1时,信号将被发送给进程组ID为-pid的所有进程

不能直接向线程发送信号。在Linux中,可以使用tgkill系统调用来向特定线程发送信号。它的函数原型如下:

#include <sys/syscall.h>
int tgkill(int tgid, int tid, int sig);

其中,tgid参数指定了线程组ID(即主线程的线程ID),tid参数指定了线程ID,而sig参数指定了要发送的信号。

请注意,glibc并没有提供对这个系统调用的封装,因此如果想使用这个函数,需要采用syscall的方式,如下:

ret = syscall(SYS_tgkill, tgid, tid, sig);

如果想向当前进程传递信号:

int raise(int sig);

如果想自定义信号处理函数,可以使用signal系统调用:

// 函数原型
void (*signal(int sig, void (*func)(int)))(int);

// 示例
#include <signal.h>
void handler(int sig) {
    // 处理 SIGINT 信号
}

signal(SIGINT, handler);

在 pthread 库中,信号处理函数与传统的信号处理函数略有不同。pthread 库提供了一组新的接口来处理信号,这些接口可以在多线程程序中安全地使用,避免了传统信号处理函数中可能出现的竞态条件和不可预测的行为。

pthread 库中的信号处理函数包括 pthread_sigmask()pthread_kill()

  1. pthread_sigmask()

pthread_sigmask() 函数可以用来设置线程的信号屏蔽集。信号屏蔽集是一组信号,当它们被屏蔽时,不会被传递到线程中。此函数的原型如下:

#include <signal.h>

int pthread_sigmask(int how, const sigset_t *set, sigset_t *oldset);

其中,how 参数指定了如何修改信号屏蔽集,可以取以下值:

  • SIG_BLOCK:将 set 中的信号添加到线程的信号屏蔽集中。
  • SIG_UNBLOCK:将 set 中的信号从线程的信号屏蔽集中移除。
  • SIG_SETMASK:将线程的信号屏蔽集设置为 set 中的信号集。

set 参数是一个指向信号集的指针,指定要添加或移除的信号集。

oldset 参数是一个指向旧信号屏蔽集的指针,用于存储函数调用前线程的信号屏蔽集。如果不需要保存旧信号屏蔽集,则可以将该参数设置为 NULL。

  1. pthread_kill()

pthread_kill() 函数用于向指定线程发送信号。该函数的原型如下:

#include <signal.h>

int pthread_kill(pthread_t thread, int sig);

其中,thread 参数指定要发送信号的线程,sig 参数指定要发送的信号。

注意,与 kill() 函数不同,pthread_kill() 函数发送的信号只会影响指定线程,不会影响整个进程。

  1. 信号处理函数

在多线程程序中,每个线程都可以设置自己的信号处理函数,用于处理收到的信号。可以使用 sigaction() 函数来注册信号处理函数,如下所示:

#include <signal.h>

struct sigaction {
   void (*sa_handler)(int);
   void (*sa_sigaction)(int, siginfo_t *, void *);
   sigset_t sa_mask;
   int sa_flags;
   void (*sa_restorer)(void);
};

int sigaction(int signum, const struct sigaction *act,
			 struct sigaction *oldact);

其中,signum 参数指定要注册的信号,act 参数是指向 sigaction 结构的指针,用于设置信号处理函数和其他相关属性。oldact 参数是指向旧信号处理函数的指针,用于存储函数调用前的信号处理函数。如果不需要保存旧信号处理函数,则可以将该参数设置为 NULL。

sa_handler 成员指定了简单的信号处理函数,它接收一个整数参数,表示收到的信号编号。如果设置了 sa_sigaction 成员,那么它将作为扩展处理函数被调用,而不是简单的信号处理函数。sa_mask 成员指定了在信号处理函数执行期间阻塞的信号集。sa_flags 成员指定了其他标志,例如是否启用 SA_RESTART 标志以自动重启被中断的系统调用。

在信号处理函数中,可以调用 pthread_sigmask() 函数来设置线程的信号屏蔽集,保护信号处理函数中的临界区,避免竞态条件和其他线程的干扰。

线程撤销(Thread Cancellation)

有时候,有可能其他线程先一步完成了任务,其他线程不用继续;或者出现了其他情况(比如用户突然停止加载网页),需要紧急停止线程。这时候就需要进行线程撤销

有两种类型的撤销:

  • 异步撤销(Asynchronous cancellation),一个线程立即终止目标线程
  • 延迟撤销(Deferred cancellation),一个线程指示目标线程应该被撤销,目标线程不断检查自己是否应该被撤销,实现了有序性

如果资源已经被分配给已撤销的线程,或者线程在更新与其他线程共享的数据,撤销会有困难(尤其对异步撤销)。通常,操作系统会释放部分系统资源而不是全部。(因此,异步撤销可能不会释放必要的系统资源)

相反,由于延迟撤销的有序性,线程撤销会更加安全

要实现线程撤销,可以在主线程中调用 pthread_cancel() 函数,向目标线程发送取消请求:

pthread_t thread;
// 创建新线程并启动
pthread_create(&thread, NULL, thread_func, NULL);
// 撤销线程
pthread_cancel(thread);

如果想要设置撤销的模式,需要在线程调用的函数中设置。pthread库支持三种撤销模式,每种模式都有一个state域和type域,可以在线程调用的函数中设置

注意,在线程创建时,默认的取消状态是不启用(PTHREAD_CANCEL_DISABLE)

文内图片

要实现延迟撤销,可以在目标线程中设置取消状态和取消点。

void* thread_func(void* arg) {
    // 设置取消状态和取消点
    pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL);
    pthread_setcanceltype(PTHREAD_CANCEL_DEFERRED, NULL);
    // 执行线程操作
    while (1) {
        // 在循环中检查取消请求并处理
        pthread_testcancel();
        // ...
    }
    return NULL;
}

在这个例子中,线程函数中使用了一个无限循环,并在循环中调用了 pthread_testcancel() 函数,以检查是否有取消请求。同时,在线程启动时,通过 pthread_setcancelstate() 和 pthread_setcanceltype() 函数设置了取消状态和取消点。这样,当取消请求到达时,线程会在下一个取消点时退出。

相对来说,Linux更推荐延迟撤销的方法

在Linux中,pthread API使用信号来实现线程撤销,因此,如果不使用pthread库,同样可以使用kill()系统调用来传递信号实现线程撤销。如果要实现延迟撤销,可以自定义信号处理函数(比如,可以定义一个全局的标识位,一旦传入撤销信号,就设置该标识位,然后在线程函数中检查该标识位)。

线程本地存储

虽然线程共享很多数据,但它也需要一些自己私有的数据,这种数据叫做线程本地存储(Thread Local Storage, TLS)

TLS不是局部变量,更类似于静态变量,但只对所属的线程可见

调度器激活

在我们使用用户线程库管理用户线程时,虽然可以无需内核支持,但是总有使用到内核的地方(比如系统调用),这时,就需要有一种办法进行内核与用户线程库之间的通信。使用调度器激活(Scheduler Activations)可以实现内核与用户线程库之间的通信

轻量级进程(Lightweight Process,LWP)

轻量级进程(LWP)是建立在内核之上(位于用户线程和内核线程之间)并由内核支持的用户线程,它是内核线程的高度抽象,每一个轻量级进程都与一个特定的内核线程关联。

在支持LWP的操作系统中,每个用户线程都会映射到一个LWP上,而每个LWP又会映射到一个内核线程上。这种设计允许操作系统在用户线程和内核线程之间提供更多的控制和调度能力。

文内图片

对于线程库来说,LWP就好像虚拟处理器,可以调度用户线程运行在LWP上

To the user-thread library, the LWP appears to be a virtual processor on which the application can schedule a user thread to run. Each LWP is attached to a kernel thread, and it is kernel threads that the operating system schedules to run on physical processors.

由于本质是用户线程:故轻量级进程可以共享诸如地址空间,打开的文件等,只要其中一个修改共享资源,另一个就立即查看这种修改

同时由于与内核进程相关联:所以每个LWP都可以作为独立单元由Kernel独立调度,同时由于内核线程位于Kernel,而Kernel正是所有资源的管理者,这也LWP也就可以像独立进程一样享有专门的中断。

虽然看起来很妙,但是:

  • 大多数LWP的操作,如建立、析构以及同步,都需要进行系统调用。系统调用的代价相对较高:需要在user mode和kernel mode中切换。
  • 每个LWP都需要有一个内核线程支持,因此LWP要消耗内核资源(内核线程的栈空间)。因此一个系统不能支持大量的LWP。

在Linux中,线程和轻量级进程是同义词。它们都表示一种轻量级的执行实体,可以与其他线程共享某些资源。从kernel看,其实看到的就是LWP,而从用户态看其实就是我们所说的用户线程。

参考:LWP 轻量级进程的理解 - 知乎

总而言之,LWP使得内核在某种程度上认识了“线程”,使得内核中的最小调度单元从process变成了thread,因此“线程”是内核调度的最小单位

回调(upcall)

内核提供一组虚拟处理器LWP给应用程序来运行用户线程。当发生某种事件时,内核会通知应用程序,这就叫做回调(upcall)(可以理解为向上面打电话)。这些回调可以由线程库提供的回调处理程序(upcall handler)处理,而这个回调处理程序又需要虚拟处理器来支持,所以此时内核同时会提供一个新的LWP给线程库