//出处已不明
一.基础知识:线程和进程
按照教科书上的定义,进程是资源管理的最小单位,线程是程序执行的最小单位。在操做系统设计上,从进程演化出线程,最主要的目的就是更好的支持SMP以及减少(进程/线程)上下文切换开销。
不管按照怎样的分法,一个进程至少须要一个线程做为它的指令执行体,进程管理着资源(好比cpu、内存、文件等等),而将线程分配到某个cpu上执行。一个进程固然能够拥有多个线程,此时,若是进程运行在SMP机器上,它就能够同时使用多个cpu来执行各个线程,达到最大程度的并行,以提升效率;同时,即便是在单cpu的机器上,采用多线程模型来设计程序,正如当年采用多进程模型代替单进程模型同样,使设计更简洁、功能更完备,程序的执行效率也更高,例如采用多个线程响应多个输入,而此时多线程模型所实现的功能实际上也能够用多进程模型来实现,而与后者相比,线程的上下文切换开销就比进程要小多了,从语义上来讲,同时响应多个输入这样的功能,实际上就是共享了除cpu之外的全部资源的。
针对线程模型的两大意义,分别开发出了核心级线程和用户级线程两种线程模型,分类的标准主要是线程的调度者在核内仍是在核外。前者更利于并发使用多处理器的资源,然后者则更多考虑的是上下文切换开销。在目前的商用系统中,一般都将二者结合起来使用,既提供核心线程以知足smp系统的须要,也支持用线程库的方式在用户态实现另外一套线程机制,此时一个核心线程同时成为多个用户态线程的调度者。正如不少技术同样,"混合"一般都能带来更高的效率,但同时也带来更大的实现难度,出于"简单"的设计思路,Linux从一开始就没有实现混合模型的计划,但它在实现上采用了另外一种思路的"混合"。
在线程机制的具体实现上,能够在操做系统内核上实现线程,也能够在核外实现,后者显然要求核内至少实现了进程,而前者则通常要求在核内同时也支持进程。核心级线程模型显然要求前者的支持,而用户级线程模型则不必定基于后者实现。这种差别,正如前所述,是两种分类方式的标准不一样带来的。
当核内既支持进程也支持线程时,就能够实现线程-进程的"多对多"模型,即一个进程的某个线程由核内调度,而同时它也能够做为用户级线程池的调度者,选择合适的用户级线程在其空间中运行。这就是前面提到的"混合" 线程模型,既可知足多处理机系统的须要,也能够最大限度的减少调度开销。绝大多数商业操做系统(如Digital Unix、Solaris、Irix)都采用的这种可以彻底实现POSIX1003.1c标准的线程模型。在核外实现的线程又能够分为"一对一"、"多对一"两种模型,前者用一个核心进程(也许是轻量进程)对应一个线程,将线程调度等同于进程调度,交给核心完成,然后者则彻底在核外实现多线程,调度也在用户态完成。后者就是前面提到的单纯的用户级线程模型的实现方式,显然,这种核外的线程调度器实际上只须要完成线程运行栈的切换,调度开销很是小,但同时由于核心信号(不管是同步的仍是异步的)都是以进程为单位的,于是没法定位到线程,因此这种实现方式不能用于多处理器系统,而这个需求正变得愈来愈大,所以,在现实中,纯用户级线程的实现,除算法研究目的之外,几乎已经消失了。
Linux内核只提供了轻量进程的支持,限制了更高效的线程模型的实现,但Linux着重优化了进程的调度开销,必定程度上也弥补了这一缺陷。目前最流行的线程机制LinuxThreads所采用的就是线程-进程"一对一"模型,调度交给核心,而在用户级实现一个包括信号处理在内的线程管理机制。Linux-LinuxThreads的运行机制正是本文的描述重点。
二.Linux 2.4内核中的轻量进程实现
最初的进程定义都包含程序、资源及其执行三部分,其中程序一般指代码,资源在操做系统层面上一般包括内存资源、IO资源、信号处理等部分,而程序的执行一般理解为执行上下文,包括对cpu的占用,后来发展为线程。在线程概念出现之前,为了减少进程切换的开销,操做系统设计者逐渐修正进程的概念,逐渐容许将进程所占有的资源从其主体剥离出来,容许某些进程共享一部分资源,例如文件、信号,数据内存,甚至代码,这就发展出轻量进程的概念。Linux内核在2.0.x版本就已经实现了轻量进程,应用程序能够经过一个统一的 clone()系统调用接口,用不一样的参数指定建立轻量进程仍是普通进程。在内核中,clone()调用通过参数传递和解释后会调用do_fork(),这个核内函数同时也是fork()、vfork()系统调用的最终实现:
int do_fork(unsigned long clone_flags, unsigned long stack_start,
struct pt_regs *regs, unsigned long stack_size)
其中的clone_flags取自如下宏的"或"值:
#define CSIGNAL 0x000000ff /* signal mask to be sent at exit */
#define CLONE_VM 0x00000100 /* set if VM shared between processes */
#define CLONE_FS 0x00000200 /* set if fs info shared between processes */
#define CLONE_FILES 0x00000400 /* set if open files shared between processes */
#define CLONE_SIGHAND 0x00000800 /* set if signal handlers and blocked signals shared */
#define CLONE_PID 0x00001000 /* set if pid shared */
#define CLONE_PTRACE 0x00002000 /* set if we want to let tracing continue on the child too */
#define CLONE_VFORK 0x00004000 /* set if the parent wants the child to wake it up on mm_release */
#define CLONE_PARENT 0x00008000 /* set if we want to have the same parent as the cloner */
#define CLONE_THREAD 0x00010000 /* Same thread group? */
#define CLONE_NEWNS 0x00020000 /* New namespace group? */
#define CLONE_SIGNAL (CLONE_SIGHAND | CLONE_THREAD)
在 do_fork()中,不一样的clone_flags将致使不一样的行为,对于LinuxThreads,它使用(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND)参数来调用clone()建立"线程",表示共享内存、共享文件系统访问计数、共享文件描述符表,以及共享信号处理方式。本节就针对这几个参数,看看Linux内核是如何实现这些资源的共享的。
1.CLONE_VM
do_fork()须要调用copy_mm()来设置task_struct中的mm和active_mm项,这两个mm_struct数据与进程所关联的内存空间相对应。若是 do_fork()时指定了CLONE_VM开关,copy_mm()将把新的task_struct中的mm和active_mm设置成与 current的相同,同时提升该mm_struct的使用者数目(mm_struct::mm_users)。也就是说,轻量级进程与父进程共享内存地址空间,由下图示意能够看出mm_struct在进程中的地位:
2.CLONE_FS
task_struct 中利用fs(struct fs_struct *)记录了进程所在文件系统的根目录和当前目录信息,do_fork()时调用copy_fs()复制了这个结构;而对于轻量级进程则仅增长 fs->count计数,与父进程共享相同的fs_struct。也就是说,轻量级进程没有独立的文件系统相关的信息,进程中任何一个线程改变当前目录、根目录等信息都将直接影响到其余线程。
3.CLONE_FILES
一个进程可能打开了一些文件,在进程结构 task_struct中利用files(struct files_struct *)来保存进程打开的文件结构(struct file)信息,do_fork()中调用了copy_files()来处理这个进程属性;轻量级进程与父进程是共享该结构的,copy_files() 时仅增长files->count计数。这一共享使得任何线程都能访问进程所维护的打开文件,对它们的操做会直接反映到进程中的其余线程。
4.CLONE_SIGHAND
每个Linux进程均可以自行定义对信号的处理方式,在task_struct中的sig(struct signal_struct)中使用一个struct k_sigaction结构的数组来保存这个配置信息,do_fork()中的copy_sighand()负责复制该信息;轻量级进程不进行复制,而仅仅增长signal_struct::count计数,与父进程共享该结构。也就是说,子进程与父进程的信号处理方式彻底相同,并且能够相互更改。
do_fork()中所作的工做不少,在此不详细描述。对于SMP系统,全部的进程fork出来后,都被分配到与父进程相同的cpu上,一直到该进程被调度时才会进行cpu选择。
尽管Linux支持轻量级进程,但并不能说它就支持核心级线程,由于Linux的"线程"和"进程"实际上处于一个调度层次,共享一个进程标识符空间,这种限制使得不可能在Linux上实现彻底意义上的POSIX线程机制,所以众多的Linux线程库实现尝试都只能尽量实现POSIX的绝大部分语义,并在功能上尽量逼近。
三.LinuxThread的线程机制
LinuxThreads是目前Linux平台上使用最为普遍的线程库,由Xavier Leroy (Xavier.Leroy@inria.fr)负责开发完成,并已绑定在GLIBC中发行。它所实现的就是基于核心轻量级进程的"一对一"线程模型,一个线程实体对应一个核心轻量级进程,而线程之间的管理在核外函数库中实现。
1.线程描述数据结构及实现限制
LinuxThreads 定义了一个struct _pthread_descr_struct数据结构来描述线程,并使用全局数组变量__pthread_handles来描述和引用进程所辖线程。在 __pthread_handles中的前两项,LinuxThreads定义了两个全局的系统线程:__pthread_initial_thread 和__pthread_manager_thread,并用__pthread_main_thread表征 __pthread_manager_thread的父线程(初始为__pthread_initial_thread)。
struct _pthread_descr_struct是一个双环链表结构,__pthread_manager_thread所在的链表仅包括它一个元素,实际上,__pthread_manager_thread是一个特殊线程,LinuxThreads仅使用了其中的errno、p_pid、 p_priority等三个域。而__pthread_main_thread所在的链则将进程中全部用户线程串在了一块儿。通过一系列 pthread_create()以后造成的__pthread_handles数组将以下图所示:
图2 __pthread_handles数组结构
新建立的线程将首先在__pthread_handles数组中占据一项,而后经过数据结构中的链指针连入以__pthread_main_thread为首指针的链表中。这个链表的使用在介绍线程的建立和释放的时候将提到。
LinuxThreads 遵循POSIX1003.1c标准,其中对线程库的实现进行了一些范围限制,好比进程最大线程数,线程私有数据区大小等等。在LinuxThreads的实现中,基本遵循这些限制,但也进行了必定的改动,改动的趋势是放松或者说扩大这些限制,使编程更加方便。这些限定宏主要集中在sysdeps/unix /sysv/linux/bits/local_lim.h(不一样平台使用的文件位置不一样)中,包括以下几个:
每进程的私有数据key 数,POSIX定义_POSIX_THREAD_KEYS_MAX为128,LinuxThreads使用PTHREAD_KEYS_MAX,1024;私有数据释放时容许执行的操做数,LinuxThreads与POSIX一致,定义PTHREAD_DESTRUCTOR_ITERATIONS为4;每进程的线程数,POSIX定义为64,LinuxThreads增大到1024(PTHREAD_THREADS_MAX);线程运行栈最小空间大小,POSIX未指定,LinuxThreads使用PTHREAD_STACK_MIN,16384(字节)。
2.管理线程
" 一对一"模型的好处之一是线程的调度由核心完成了,而其余诸如线程取消、线程间的同步等工做,都是在核外线程库中完成的。在LinuxThreads中,专门为每个进程构造了一个管理线程,负责处理线程相关的管理工做。当进程第一次调用pthread_create()建立一个线程的时候就会建立(__clone())并启动管理线程。
在一个进程空间内,管理线程与其余线程之间经过一对"管理管道(manager_pipe[2])"来通信,该管道在建立管理线程以前建立,在成功启动了管理线程以后,管理管道的读端和写端分别赋给两个全局变量 __pthread_manager_reader和__pthread_manager_request,以后,每一个用户线程都经过 __pthread_manager_request向管理线程发请求,但管理线程自己并无直接使用 __pthread_manager_reader,管道的读端(manager_pipe[0])是做为__clone()的参数之一传给管理线程的,管理线程的工做主要就是监听管道读端,并对从中取出的请求做出反应。
建立管理线程的流程以下所示:
(全局变量pthread_manager_request初值为-1)
图3 建立管理线程的流程
初始化结束后,在__pthread_manager_thread中记录了轻量级进程号以及核外分配和管理的线程 id,2*PTHREAD_THREADS_MAX+1这个数值不会与任何常规用户线程id冲突。管理线程做为pthread_create()的调用者线程的子线程运行,而pthread_create()所建立的那个用户线程则是由管理线程来调用clone()建立,所以其实是管理线程的子线程。(此处子线程的概念应该看成子进程来理解。)
__pthread_manager()就是管理线程的主循环所在,在进行一系列初始化工做后,进入while(1)循环。在循环中,线程以2秒为timeout查询(__poll())管理管道的读端。在处理请求前,检查其父线程(也就是建立manager的主线程)是否已退出,若是已退出就退出整个进程。若是有退出的子线程须要清理,则调用 pthread_reap_children()清理。
而后才是读取管道中的请求,根据请求类型执行相应操做(switch-case)。具体的请求处理,源码中比较清楚,这里就不赘述了。
3.线程栈
在LinuxThreads中,管理线程的栈和用户线程的栈是分离的,管理线程在进程堆中经过malloc()分配一个THREAD_MANAGER_STACK_SIZE字节的区域做为本身的运行栈。
用户线程的栈分配办法随着体系结构的不一样而不一样,主要根据两个宏定义来区分,一个是NEED_SEPARATE_REGISTER_STACK,这个属性仅在IA64平台上使用;另外一个是FLOATING_STACK宏,在i386等少数平台上使用,此时用户线程栈由系统决定具体位置并提供保护。与此同时,用户还能够经过线程属性结构来指定使用用户自定义的栈。因篇幅所限,这里只能分析i386平台所使用的两种栈组织方式:FLOATING_STACK方式和用户自定义方式。
在FLOATING_STACK方式下,LinuxThreads利用mmap()从内核空间中分配8MB空间(i386系统缺省的最大栈空间大小,若是有运行限制(rlimit),则按照运行限制设置),使用mprotect()设置其中第一页为非访问区。该 8M空间的功能分配以下图:
图4 栈结构示意
低地址被保护的页面用来监测栈溢出。
对于用户指定的栈,在按照指针对界后,设置线程栈顶,并计算出栈底,不作保护,正确性由用户本身保证。
不论哪一种组织方式,线程描述结构老是位于栈顶紧邻堆栈的位置。
4.线程id和进程id
每一个LinuxThreads线程都同时具备线程id和进程id,其中进程id就是内核所维护的进程号,而线程id则由LinuxThreads分配和维护。
__pthread_initial_thread 的线程id为PTHREAD_THREADS_MAX,__pthread_manager_thread的是 2*PTHREAD_THREADS_MAX+1,第一个用户线程的线程id为PTHREAD_THREADS_MAX+2,此后第n个用户线程的线程 id遵循如下公式:
tid=n*PTHREAD_THREADS_MAX+n+1
这种分配方式保证了进程中全部的线程(包括已经退出)都不会有相同的线程id,而线程id的类型pthread_t定义为无符号长整型(unsigned long int),也保证了有理由的运行时间内线程id不会重复。
从线程id查找线程数据结构是在pthread_handle()函数中完成的,实际上只是将线程号按PTHREAD_THREADS_MAX取模,获得的就是该线程在__pthread_handles中的索引。
5.线程的建立
在 pthread_create()向管理线程发送REQ_CREATE请求以后,管理线程即调用pthread_handle_create()建立新线程。分配栈、设置thread属性后,以pthread_start_thread()为函数入口调用__clone()建立并启动新线程。 pthread_start_thread()读取自身的进程id号存入线程描述结构中,并根据其中记录的调度方法配置调度。一切准备就绪后,再调用真正的线程执行函数,并在此函数返回后调用pthread_exit()清理现场。
6.LinuxThreads的不足
因为Linux内核的限制以及实现难度等等缘由,LinuxThreads并非彻底POSIX兼容的,在它的发行README中有说明。
1)进程id问题
这个不足是最关键的不足,引发的缘由牵涉到LinuxThreads的"一对一"模型。
Linux 内核并不支持真正意义上的线程,LinuxThreads是用与普通进程具备一样内核调度视图的轻量级进程来实现线程支持的。这些轻量级进程拥有独立的进程id,在进程调度、信号处理、IO等方面享有与普通进程同样的能力。在源码阅读者看来,就是Linux内核的clone()没有实现对 CLONE_PID参数的支持。
在内核do_fork()中对CLONE_PID的处理是这样的:
if (clone_flags & CLONE_PID) {
if (current->pid)
goto fork_out;
}
这段代码代表,目前的Linux内核仅在pid为0的时候承认CLONE_PID参数,实际上,仅在SMP初始化,手工建立进程的时候才会使用CLONE_PID参数。
按照POSIX定义,同一进程的全部线程应该共享一个进程id和父进程id,这在目前的"一对一"模型下是没法实现的。
2)信号处理问题
因为异步信号是内核以进程为单位分发的,而LinuxThreads的每一个线程对内核来讲都是一个进程,且没有实现"线程组",所以,某些语义不符合POSIX标准,好比没有实现向进程中全部线程发送信号,README对此做了说明。
若是核心不提供实时信号,LinuxThreads将使用SIGUSR1和SIGUSR2做为内部使用的restart和cancel信号,这样应用程序就不能使用这两个本来为用户保留的信号了。在Linux kernel 2.1.60之后的版本都支持扩展的实时信号(从_SIGRTMIN到_SIGRTMAX),所以不存在这个问题。
某些信号的缺省动做难以在现行体系上实现,好比SIGSTOP和SIGCONT,LinuxThreads只能将一个线程挂起,而没法挂起整个进程。
3)线程总数问题
LinuxThreads将每一个进程的线程最大数目定义为1024,但实际上这个数值还受到整个系统的总进程数限制,这又是因为线程实际上是核心进程。
在kernel 2.4.x中,采用一套全新的总进程数计算方法,使得总进程数基本上仅受限于物理内存的大小,计算公式在kernel/fork.c的fork_init()函数中:
max_threads = mempages / (THREAD_SIZE/PAGE_SIZE) / 8
在 i386上,THREAD_SIZE=2*PAGE_SIZE,PAGE_SIZE=2^12(4KB),mempages=物理内存大小 /PAGE_SIZE,对于256M的内存的机器,mempages=256*2^20/2^12=256*2^8,此时最大线程数为4096。
但为了保证每一个用户(除了root)的进程总数不至于占用一半以上物理内存,fork_init()中继续指定:
init_task.rlim[RLIMIT_NPROC].rlim_cur = max_threads/2;
init_task.rlim[RLIMIT_NPROC].rlim_max = max_threads/2;
这些进程数目的检查都在do_fork()中进行,所以,对于LinuxThreads来讲,线程总数同时受这三个因素的限制。
4)管理线程问题
管理线程容易成为瓶颈,这是这种结构的通病;同时,管理线程又负责用户线程的清理工做,所以,尽管管理线程已经屏蔽了大部分的信号,但一旦管理线程死亡,用户线程就不得不手工清理了,并且用户线程并不知道管理线程的状态,以后的线程建立等请求将无人处理。
5)同步问题
LinuxThreads中的线程同步很大程度上是创建在信号基础上的,这种经过内核复杂的信号处理机制的同步方式,效率一直是个问题。
6)其余POSIX兼容性问题
Linux中不少系统调用,按照语义都是与进程相关的,好比nice、setuid、setrlimit等,在目前的LinuxThreads中,这些调用都仅仅影响调用者线程。
7)实时性问题
线程的引入有必定的实时性考虑,但LinuxThreads暂时不支持,好比调度选项,目前尚未实现。不只LinuxThreads如此,标准的Linux在实时性上考虑都不多。