Linux上线程的实现

0、Linux线程历史

在操作系统设计上,从进程演化出线程,最主要的目的就是更好的支持SMP以及减小(进程/线程)上下文切换开销。针对线程的这两大意义,分别开发出了内核级线程和用户级线程两种线程模型,分类的标准主要是线程的调度者在核内还是在核外。前者更利于并发使用多处理器的资源,而后者则更多考虑的是上下文切换开销。在目前的商用系统中,通常都将两者结合起来使用,既提供核心线程以满足smp系统的需要,也支持用线程库的方式在用户态实现另一套线程机制,此时一个核心线程同时成为多个用户态线程的调度者。正如很多技术一样,"混合"通常都能带来更高的效率,但同时也带来更大的实现难度。

在线程机制的具体实现上,可以在操作系统内核上实现线程,也可以在核外实现。

  • 当核内既支持进程也支持线程时,就可以实现线程-进程的"多对多"模型,即一个进程的某个线程由核内调度,而同时它也可以作为用户级线程池的调度者,选择合适的用户级线程在其空间中运行。这就是前面提到的"混合"线程模型,既可满足多处理机系统的需要,也可以最大限度的减小调度开销。绝大多数商业操作系统(如Digital Unix、Solaris、Irix)都采用的这种能够完全实现POSIX1003.1c标准的线程模型。
  • 在核外实现的线程又可以分为"一对一"、"多对一"两种模型,前者用一个核心进程(也许是轻量进程)对应一个线程,将线程调度等同于进程调度,交给核心完成,而后者则完全在核外实现多线程,调度也在用户态完成。后者就是前面提到的单纯的用户级线程模型的实现方式,显然,这种核外的线程调度器实际上只需要完成线程运行栈的切换,调度开销非常小,但同时因为核心信号(无论是同步的还是异步的)都是以进程为单位的,因而无法定位到线程,所以这种实现方式不能用于多处理器系统,而这个需求正变得越来越大,因此在现实中,纯用户级线程的实现,除算法研究目的外几乎已经消失了。

0.1)Pthreads概念:

Pthreads 是 IEEE(电子和电气工程师协会)委员会开发的一组线程接口,Pthreads 中的 P 表示 POSIX,实际上Pthreads 也代表 POSIX 线程。POSIX 委员会定义了一系列基本功能和数据结构,希望能够被大量厂商采用,以便线程代码能够轻松地在各种操作系统上移植,委员会的梦想由 UNIX 厂商实现了。

0.2)Linux是否支持线程:

一直以来linux内核并没有线程的概念,每一个执行实体都是一个task_struct结构,通常称之为进程。Linux内核在 2.0.x版本就已经实现了轻量进程(LWP),应用程序可以通过一个统一的clone()系统调用接口,用不同的参数指定创建轻量进程还是普通进程。在内核中, clone()调用经过参数传递和解释后会调用do_fork(),这个核内函数同时也是fork()、vfork()系统调用的最终实现。后来为了引入多线程,在Linux2.6中引入了线程组。目前的Linux支持:内核线程、LWP以及用户进程、线程组的概念:

  • 内核线程:内核创建的线程,处理内核事件的,例如kthreadd,ksoftirqd,migration这些都是内核线程,只运行在内核态。
  • 用户进程:用户通过fork创建的程序。对于SMP系统,所有的进程fork出来后,都被分配到与父进程相同的cpu上,一直到该进程被调度时才会进行cpu选择。(在linux中只有task_struct一种数据结构,就是进程)
  • LWP:从Linux2.0开始支持,通过clone创建的进程(内部也是调用了fork),由于LWP和父进程会共享部分资源,比如地址空间,文件系统,文件句柄,信号处理函数等,所以把LWP称为轻量级进程。
  • 线程组:从Linux2.6开始支持,其实就是在task_struct中增加了tgid(thread group id)字段,一般认为Linux通过这种方式支持了线程,其中进程的tgid等于自己的pid,线程的tgid等于进程的pid。

1)早期Linux只有进程:

最初的Linux内核只支持通过fork来创建进程。在线程概念出现以前,为了减小进程切换的开销,操作系统设计者逐渐修正进程的概念,在Linux2.0开始支持轻量级进程(LWP),即:允许将进程所占有的资源从其主体剥离出来,允许某些进程共享一部分资源,例如文件、信号,数据内存,甚至代码。

尽管Linux支持轻量级进程,但并不能说它就支持核心级线程,因为Linux的"线程"和"进程"实际上处于一个调度层次,共享一个进程标识符空间,这种限制使得不可能在Linux上实现完全意义上的POSIX线程机制,因此众多的Linux线程库实现尝试都只能尽可能实现POSIX的绝大部分语义,并在功能上尽可能逼近。在2.0~2.4时代的线程库LinuxThreads,就处于这种尴尬的境地。

2)Linux2.6支持线程组:

在2.6的内核版本中,并不是在Linux内核中加了一个“线程”,仍然和原来一样,使用的是clone实现的轻量级进程(其实,进程线程就是个概念,对于计算机只要能高效的实现这个概念就行),内核又在task_struct中增加了tgid(thread group id)字段,从而实现了线程组。但是和真正意义上的“内核级”线程还是有区别的。

我们知道,如果操作系统没有提供“创建线程”的函数(比如Linux 2.4及以前的版本,因为Linux刚诞生那时候,还没有“线程”的概念,能处理多“进程”就不错了),当然你程序员也没办法在操作系统上创建线程。所以,Linux 2.4内核中不知道什么是“线程”,只有一个“task_struct”的数据结构,就是进程。那么,后来随着科学技术的发展,大家提出线程的概念,而且线程的确是个好东西,于是我们希望Linux能加入“多线程”编程。但要修改一个操作系统,那是很复杂的事情,特别是当操作系统越来越庞大的时候,况且Linux还是开源的。怎么才能让Linux支持“多线程”呢?最简单的,就是不去动操作系统的“内核”,而是写一个函数库来“模拟”线程。

0.3)Linux线程库:

Linux线程库主要有LinuxThreads和NPTL(Native POSIX Thread Library),LinuxThreads逐渐被NPTL取代。LinuxThreads和NPTL都是1:1的调度模型,用户线程其实就是通过clone创建的LWP。

  1. LinuxThreads:最早的线程库,是通过管理线程对线程进行管理的,因而在SMP系统上伸缩性较差,并且很多方面不遵循POSIX 标准;
  2. NPTL(native posix thread library):RedHat研发的线程库,由于Linux支持了线程组,所以它不需要管理线程,实现也更符合 POSIX 的需求;
  3. NGPT(Next Generation POSIX Threads):IBM研发的线程库,实现了M:N的线程模型;

2003年3月NGPT官方网站上的通知,NGPT考虑到NPTL日益广泛地为人所接受,为避免不同的线程库版本引起的混乱,今后将不再进行进一步开发。也就是说,NGPT已经放弃与NPTL竞争下一代Linux POSIX线程库标准。

说明:Linux2.0~2.4实现的是俗称LinuxThreads的多线程方式,到了2.6基本上都是NPTL的方式了。

注:这NPTL也并不是完美的,还有一些小问题,像有一些商业操作系统,可以实现N:M混合模型(就是内核线程和用户线程的对应关系),所以Linux仍有改进的空间。

0.4)发展史:

1999年1月发布的Linux 2.2内核中,进程是通过系统调用fork创建的,新的进程是原来进程的子进程。需要说明的是,在2.2.x版本中,不存在真正意义上的线程(thread)。Linux中常用的线程Pthread实际上是通过进程来模拟的。也就是说Linux中的线程也是通过fork创建的,是“轻”进程。Linux 2.2只默认允许4096个进程/线程同时运行。高端系统同时要服务上千个用户,所以这显然是一个问题,它一度是阻碍Linux进入企业级市场的一大因素。

2001年1月发布的Linux 2.4内核消除了这个限制,并且允许在系统运行中动态调整进程数上限。因此,进程数现在只受制于物理内存的多少。在高端服务器上,即使安装了512MB内存,现在也能轻而易举地同时支持1万6千个进程。

2003年12月发布的2.6内核,进程调度经过重新编写,去掉了以前版本中效率不高的算法。以前,为了决定下一步要运行哪一个任务,进程调度程序要查看每一个准备好的任务,并且经过计算机来决定哪一个任务相对来更为重要。进程标识号(PID)的数目也从32000升到10亿。内核内部的大改变之一就是Linux的线程框架被重写,以使NPTL(Native POSIX Thread Library)可以运行于其上。对于运行负荷繁重的线程应用的Pentium Pro及更先进的处理器而言,这是一个主要的性能提升,也是企业级应用中的很多高端系统一直以来所期待的。线程框架的改变包含Linux线程空间中的许多新的概念,包括线程组、线程各自的本地存储区、POSIX风格信号,以及其他改变。改进后的多线程和内存管理技术有助于更好地运行大型多媒体应用软件。这个线程库有以下几个目标: POSIX兼容,都处理结果和应用,底启动开销,低链接开销,与Linux Thread应用的二进制兼容,软硬件的可扩展能力,与C++集成等。 这一切是2.6的内核多线程机制更加完备。

1、LinuxThreads

LinuxThreads是Linux平台上最早的线程库,并已绑定在GLIBC中发行。它所实现的就是基于核心轻量级进程的"一对一"线程模型,一个线程实体对应一个核心轻量级进程,而线程之间的管理在核外线程函数库中实现。

linux 2.6以前(主要是2.0~2.4),pthread线程库对应的实现是一个名叫linuxthreads的lib,本质上是通过LWP实现的1:1模型,即通过LWP来模拟线程,内核并不知道有线程这个概念,在内核看来都是进程。这个模型最大的好处是线程调度由内核完成了,而其他线程操作(同步、取消)等都是核外的线程库函数完成的。

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中,专门为每一个进程构造了一个管理线程,负责处理线程相关的管理工作。当进程第一次调用pthread_create()创建一个线程的时候就会创建并启动管理线程。然后管理线程再来创建用户请求的线程。也就是说,用户在调用pthread_create后,先是创建了管理线程,再由管理线程创建了用户的线程。

linuxthreads利用前面提到的轻量级进程来实现线程,对于POSIX提出的规范linuxthreads除了第5点以外, 都没有实现(实际上是无能为力)。但是,linuxthreads为了实现这个”第5点”, 还是付出了很多代价, 并且创造了linuxthreads本身的一大性能瓶颈。

当A程序创建了10个线程,但是ps时却会出现11个A进程了。因为linuxthreads自动创建了一个管理线程。当程序开始运行时, 并没有管理线程存在(因为尽管程序已经链接了pthread库, 但是未必会使用多线程),程序第一次调用pthread_create时,linuxthreads发现管理线程不存在,于是创建这个管理线程。这个管理线程是进程中的第一个线程(主线程)的儿子。然后在pthread_create中,会通过pipe向管理线程发送一个命令,告诉它创建线程。即是说除主线程外,所有的线程都是由管理线程来创建的,管理线程是它们的父亲。

于是,当任何一个子线程退出时,管理线程将收到SIGUSER1信号(这是在通过clone创建子线程时指定的),管理线程在对应的sig_handler中会判断子线程是否正常退出,如果不是,则杀死所有线程,然后自杀。

那么, 主线程怎么办呢? 主线程是管理线程的父亲,其退出时并不会给管理线程发信号。于是,在管理线程的主循环中通过getppid检查父进程的ID号,如果ID号是1,说明父亲已经退出,并把自己托管给了init进程(1号进程)。这时候,管理线程也会杀掉所有子线程,然后自杀。

可见,线程的创建与销毁都是通过管理线程来完成的,于是管理线程就成了linuxthreads的一个性能瓶颈。创建与销毁需要一次进程间通信,一次上下文切换之后才能被管理线程执行,并且多个请求会被管理线程串行地执行。

这种通过LWP的方式来模拟线程的实现看起来还是比较巧妙的,但也存在一些比较严重的问题:

  1. 线程ID和进程ID的问题:按照POSIX的定义,同一进程的所有的线程应该共享同一个进程和父进程ID,而Linux的这种LWP方式显然不能满足这一点。
  2. 信号处理问题:异步信号是以进程为单位分发的,而Linux的线程本质上每个都是一个进程,且没有进程组的概念,所以某些缺省信号难以做到对所有线程有效,例如SIGSTOP和SIGCONT,就无法将整个进程挂起,而只能将某个线程挂起。
  3. 线程总数问题:LinuxThreads将每个进程的线程最大数目定义为1024,但实际上这个数值还受到整个系统的总进程数限制,这又是由于线程其实是核心进程。
  4. 管理线程问题:管理线程容易成为瓶颈,这是这种结构的通病;同时,管理线程又负责用户线程的清理工作,因此,尽管管理线程已经屏蔽了大部分的信号,但一旦管理线程死亡,用户线程就不得不手工清理了,而且用户线程并不知道管理线程的状态,之后的线程创建等请求将无人处理。
  5. 同步问题:LinuxThreads中的线程同步很大程度上是建立在信号基础上的,这种通过内核复杂的信号处理机制的同步方式,效率一直是个问题。
  6. 其他POSIX兼容性问题:Linux中很多系统调用,按照语义都是与进程相关的,比如nice、setuid、setrlimit等,在目前的LinuxThreads中,这些调用都仅仅影响调用者线程。
  7. 实时性问题:线程的引入有一定的实时性考虑,但LinuxThreads暂时不支持,比如调度选项,目前还没有实现。不仅LinuxThreads如此,标准的Linux在实时性上考虑都很少

2、NPTL

到了linux 2.6,glibc中有了一种新的pthread线程库NPTL(Native POSIX Threading Library)。本质上,NPTL也是通过LWP实现的1:1的模型。NPTL实现了前面提到的POSIX的全部5点要求(实际上,与其说是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线程背后的故事,肯定会觉得遇到灵异事件了。

为了应付”发送给进程的信号”和”发送给线程的信号”,task_struct里面维护了两套signal_pending,一套是线程组共享的,一套是线程独有的。通过kill发送的信号被放在线程组共享的signal_pending中,可以由任意一个线程来处理;通过pthread_kill发送的信号(pthread_kill是pthread库的接口,对应的系统调用中tkill)被放在线程独有的signal_pending中, 只能由本线程来处理。当线程停止/继续,或者是收到一个致命信号时,内核会将处理动作施加到整个线程组中。

3、NGPT

上面提到的两种线程库使用的都是内核级线程(每个线程都对应内核中的一个调度实体), 这种模型称为1:1模型(1个线程对应1个内核级线程)。而NGPT则打算实现M:N模型(M个线程对应N个内核级线程),也就是说若干个线程可能是在同一个执行实体上实现的。

线程库需要在一个内核提供的执行实体上抽象出若干个执行实体,并实现它们之间的调度。这样被抽象出来的执行实体称为用户级线程。大体上,这可以通过为每个用户级线程分配一个栈,然后通过longjmp的方式进行上下文切换。(google ”setjmp/longjmp”就可知道)

但是实际上要处理的细节问题非常之多,目前的NGPT好像并没有实现所有预期的功能,并且暂时也不准备去实现。用户级线程的切换显然要比内核级线程的切换快一些,前者可能只是一个简单的长跳转,而后者则需要保存/装载寄存器,进入然后退出内核态(进程切换则还需要切换地址空间等)。而用户级线程则不能享受多处理器,因为多个用户级线程对应到一个内核级线程上,一个内核级线程在同一时刻只能运行在一个处理器上。不过,M:N的线程模型毕竟提供了这样一种手段,可以让不需要并行执行的线程运行在一个内核级线程对应的若干个用户级线程上,可以节省它们的切换开销。据说一些类UNIX系统(Solaris)已经实现了比较成熟的M:N线程模型,其性能比起linux的线程还是有着一定的优势。

参考:

https://www.jianshu.com/p/a314014695d7

https://www.zhihu.com/question/35128513

https://blog.csdn.net/mm_hh/article/details/72587207

https://blog.csdn.net/u010154760/article/details/45310585

猜你喜欢

转载自blog.csdn.net/liuxiao723846/article/details/113574739
今日推荐