Linux 线程浅析
进程和线程的区别与联系
在许多经典的操作系统教科书中,总是把进程定义为程序的执行实例,它并不执行什么, 只是维护应用程序所需的各种资源,而线程则是真正的执行实体。
为了让进程完成一定的工作,进程必须至少包含一个线程。
进程,直观点说,保存在硬盘上的程序运行以后,会在内存空间里形成一个独立的内存体,这个内存体有自己的地址空间,有自己的堆,上级挂靠单位是操作系统。操作系统会以进程为单位,分配系统资源,所以我们也说,进程是资源分配的最小单位。
线程存在与进程当中,是操作系统调度执行的最小单位。说通俗点,线程就是干活的。
如果说进程是一个资源管家,负责从主人那里要资源的话,那么线程就是干活的苦力。一个管家必须完成一项工作,就需要最少一个苦力,也就是说,一个进程最少包含一个线程,也可以包含多个线程。苦力要干活,就需要依托于管家,所以说一个线程,必须属于某一个进程。进程有自己的地址空间,线程使用进程的地址空间,也就是说,进程里的资源,线程都是有权访问的,比如说堆啊,栈啊,静态存储区什么的。
线程就是个无产阶级,但无产阶级干活,总得有自己的劳动工具吧,这个劳动工具就是栈,线程有自己的栈,这个栈仍然是使用进程的地址空间,只是这块空间被线程标记为了栈。每个线程都会有自己私有的栈,这个栈是不可以被其他线程所访问的。
进程所维护的是程序所包含的资源(静态资源), 如:地址空间,打开的文件句柄集,文件系统状态,信号处理handler,等;
线程所维护的运行相关的资源(动态资源),如:运行栈,调度相关的控制信息,待处理的信号集,等;
然而,一直以来,Linux 内核并没有线程的概念。每一个执行实体都是一个 task_struct 结构,通常称之为进程。
进程是一个执行单元,维护着执行相关的动态资源。同时,它又引用着程序所需的静态资源。通过系统调用 clone 创建子进程时,可以有选择性地让子进程共享父进程所引用的资源,这样的子进程通常称为轻量级进程。
linux 上的线程就是基于轻量级进程,由用户态的 pthread 库实现的。使用 pthread 以后,在用户看来,每一个 task_struct 就对应一个线程,而一组线程以及它们所共同引用的一组资源就是一个进程。
但是,一组线程并不仅仅是引用同一组资源就够了,它们还必须被视为一个整体。
对此,POSIX 标准提出了如下要求:
1)查看进程列表的时候,相关的一组 task_struct 应当被展现为列表中的一个节点;
2)发送给这个“进程”的信号(对应 kill 系统调用), 将被对应的这一组 task_struct 所共享,并且被其中的任意一个“线程”处理;
3)发送给某个“线程”的信号(对应 pthread_kill), 将只被对应的一个task_struct接收,并且由它自己来处理;
4)当“进程”被停止或继续时(对应 SIGSTOP/SIGCONT 信号), 对应的这一组 task_struct 状态将改变;
5)当“进程”收到一个致命信号(比如由于段错误收到 SIGSEGV 信号), 对应的这一组 task_struct 将全部退出;
6)等等(以上可能不够全)
LinuxThreads
在 linux 2.6 以前,pthread 线程库对应的实现是一个名叫 LinuxThreads 的 lib。
LinuxThreads 利用前面提到的轻量级进程来实现线程,但是对于 POSIX 提出的那些要求,LinuxThreads 除了第 5 点以外(当“进程”收到一个致命信号(比如由于段错误收到 SIGSEGV 信号), 对应的这一组 task_struct 将全部退出),都没有实现(实际上是无能为力):
1)如果运行了 A 程序,A 程序创建了 10 个线程,那么在 shell 下执行 ps 命令时将看到 11 个 A 进程,而不是 1 个(注意, 也不是10个,下面会解释);
2)不管是 kill 还是 pthread_kill,信号只能被一个对应的线程所接收;
3)SIGSTOP/SIGCONT 信号只对一个线程起作用;
还好 LinuxThreads 实现了第 5 点,我认为这一点是最重要的。如果某个线程“挂”了,整个进程还在若无其事地运行着,可能会出现很多的不一致状态。进程将不是一个整体,而线程也不能称为线程。
或许这也是为什么 LinuxThreads 虽然与 POSIX 的要求差距甚远,却能够存在,并且还被使用了好几年的原因吧~
但是,LinuxThreads 为了实现这个“第 5 点”, 还是付出了很多代价,并且创造了 LinuxThreads 本身的一大性能瓶颈。
接下来要说说,为什么 A 程序创建了 10 个线程,但是 ps 时却会出现 11 个 A 进程了。 因为 LinuxThreads 自动创建了一个管理线程。上面提到的“第5点”就是靠管理线程来实现的。
当程序开始运行时, 并没有管理线程存在(因为尽管程序已经链接了 pthread 库, 但是未必会使用多线程)。 程序第一次调用 pthread_create 时,LinuxThreads 发现管理线程不存在,于是创建这个管理线程。这个管理线程是进程中的第一个线程(主线程)的儿子。然后在 pthread_create 中,会通过 pipe 向管理线程发送一个命令,告诉它创建线程。即是说,除主线程外,所有的线程都是由管理线程来创建的,管理线程是它们的父亲。
于是,当任何一个子线程退出时,管理线程将收到 SIGUSER1 信号(这是在通过 clone 创建子线程时指定的)。管理线程在对应的 sig_handler 中会判断子线程是否正常退出,如果不是,则杀死所有线程,然后自杀。
那么,主线程怎么办呢? 主线程是管理线程的父亲,其退出时并不会给管理线程发信号。 于是,在管理线程的主循环中通过 getppid 检查父进程的 ID 号,如果 ID 号是 1,说明父亲已经退出,并把自己托管给了 init 进程(1 号进程)。这时候,管理线程也会杀掉所有子线程,然后自杀。
那么,如果主线程是调用 pthread_exit 主动退出的呢? 按照 posix 的标准,这种情况下其他子线程是应该继续运行的。于是,在 LinuxThreads 中,主线程调用 pthread_exit 以后并不会真正退出,而是会在 pthread_exit 函数中阻塞等待所有子线程都退出了, pthread_exit 才会让主线程退出。(在这个等等过程中,主线程一直处于睡眠状态。)
可见,线程的创建与销毁都是通过管理线程来完成的,于是管理线程就成了 LinuxThreads 的一个性能瓶颈。线程的创建与销毁需要一次进程间通信,一次上下文切换之后才能被管理线程执行,并且多个请求会被管理线程串行地执行。
NPTL
到了 linux 2.6,glibc 中有了一种新的 pthread 线程库 —— NPTL(Native POSIX Threading Library)。
NPTL 实现了前面提到的 POSIX 的全部5点要求:
1)查看进程列表的时候,相关的一组 task_struct 应当被展现为列表中的一个节点;
2)发送给这个“进程”的信号(对应 kill 系统调用), 将被对应的这一组 task_struct 所共享,并且被其中的任意一个“线程”处理;
3)发送给某个“线程”的信号(对应 pthread_kill), 将只被对应的一个task_struct接收,并且由它自己来处理;
4)当“进程”被停止或继续时(对应 SIGSTOP/SIGCONT 信号), 对应的这一组 task_struct 状态将改变;
5)当“进程”收到一个致命信号(比如由于段错误收到 SIGSEGV 信号), 对应的这一组 task_struct 将全部退出;
但是,实际上,与其说是 NPTL 实现了,不如说是linux内核实现了。
在 linux 2.6 中,内核有了线程组的概念,task_struct 结构中增加了一个 tgid(thread group id)字段。 如果这个 task 是一个“主线程”, 则它的 tgid 等于 pid,否则 tgid 等于进程的 pid(即主线程的 pid)。
在 clone 系统调用中,传递 CLONE_THREAD 参数就可以把新进程的 tgid 设置为父进程的 tgid(否则新进程的 tgid 会设为其自身的 pid)。
类似的 XXid 在 task_struct 中还有两个:task->signal->pgid 保存进程组的打头进程的 pid、task->signal->session 保存会话打头进程的 pid 。通过这两个 id 来关联进程组和会话。
有了 tgid,内核或相关的 shell 程序就知道某个 tast_struct 是代表一个进程还是代表一个线程,也就知道在什么时候该展现它们,什么时候不该展现(比如在 ps 的时候,线程就不要展现了)。而 getpid(获取进程 ID)系统调用返回的也是 tast_struct 中的 tgid,而 tast_struct 中的 pid 则由 gettid 系统调用来返回。
在执行 ps 命令的时候不展现子线程,也是有一些问题的。比如程序 a.out 运行时,创建了一个线程。假设主线程的 pid 是 10001、子线程是 10002(它们的 tgid 都是10001)。这时如果你 kill 10002,是可以把 10001 和 10002 这两个线程一起杀死的,尽管执行 ps 命令的时候根本看不到 10002 这个进程。如果你不知道 linux 线程背后的故事,肯定会觉得遇到灵异事件了。
下面我们一起来验证这灵异事件:
- #include <unistd.h>
- #include <stdlib.h>
- #include <stdio.h>
- #include <pthread.h>
- void *fun(void *arg)
- {
- printf("thread is created!\n");
- pause(); //挂起线程
- }
- int main(void)
- {
- pthread_t tid;
- pthread_create(&tid, NULL, fun, NULL);
- pause();//主线程挂起(否则主线程终止,子线程也就挂了)
- return 0;
- }
这个程序创建一个线程后挂起,子线程在输出 “thread is created!” 也挂起。运行结果如下图:
我们打开另外一个终端,查看后台运行的进程,发现 demo 的进程号(pid)是 2361,我们使用 kill 终止 pid 为 2362 的进程(注意 ps 中并没有这个进程),如下图:
结果发现,demo 进程也终止了,如下图。其原因就是 2362 就是所创建线程的线程号,线程异常终止了,其对应的进程也就终止了。
为了应付“发送给进程的信号”和“发送给线程的信号”, task_struct 里面维护了两套 signal_pending,一套是线程组共享的,一套是线程独有的。
通过 kill 发送的信号被放在线程组共享的 signal_pending 中,可以由任意一个线程来处理;通过 pthread_kill 发送的信号(pthread_kill 是 pthread 库的接口,对应的系统调用中 tkill)被放在线程独有的 signal_pending 中, 只能由本线程来处理。
当线程停止/继续,或者是收到一个致命信号时,内核会将处理动作施加到整个线程组中。
NGPT
说到这里,也顺便提一下 NGPT(Next Generation POSIX Threads)。
上面提到的两种线程库使用的都是内核级线程(每个线程都对应内核中的一个调度实体),这种模型称为 1:1 模型(1 个线程对应 1 个内核级线程);
而 NGPT 则打算实现 M:N 模型(M 个线程对应 N 个内核级线程),也就是说若干个线程可能是在同一个执行实体上实现的。 线程库需要在一个内核提供的执行实体上抽象出若干个执行实体,并实现它们之间的调度。这样被抽象出来的执行实体称为用户级线程。
大体上,这可以通过为每个用户级线程分配一个栈,然后通过 longjmp 的方式进行上下文切换。(百度一下"setjmp,longjmp", 你就知道。)
但是实际上要处理的细节问题非常之多,目前的 NGPT 好像并没有实现所有预期的功能,并且暂时也不准备去实现。
用户级线程的切换显然要比内核级线程的切换快一些,前者可能只是一个简单的长跳转,而后者则需要保存/装载寄存器,进入然后退出内核态。(进程切换则还需要切换地址空间等)
而用户级线程则不能享受多处理器,因为多个用户级线程对应到一个内核级线程上,一个内核级线程在同一时刻只能运行在一个处理器上。
不过,M:N 的线程模型毕竟提供了这样一种手段,可以让不需要并行执行的线程运行在一个内核级线程对应的若干个用户级线程上,可以节省它们的切换开销。
据说一些类 UNIX 系统(如 Solaris)已经实现了比较成熟的 M:N 线程模型,其性能比起 linux 的线程还是有着一定的优势。