自从多线程编程的概念呈现在 Linux 中以来,Linux 多线运用的开展总是与两个问题脱不开关连:兼容性、功率。本文从线程模型下手,经过剖析现在 Linux 渠道上最盛行的 LinuxThreads 线程库的完结及其缺乏,描绘了 Linux 社区是怎么看待和处理兼容性和功率这两个问题的。
一。基础知识:线程和进程
依照教科书上的界说,进程是资源办理的最小单位,线程是程序履行的最小单位。在操作体系规划上,从进程演化出线程,最首要的意图便是更好的支撑SMP以及减小(进程/线程)上下文切换开支。
不管依照怎样的分法,一个进程至少需求一个线程作为它的指令履行体,进程办理着资源(比方cpu、内存、文件等等),而将线程分配到某个cpu上履行。一个进程当然能够具有多个线程,此刻,假如进程运转在SMP机器上,它就能够一同运用多个cpu来履行各个线程,到达最大程度的并行,以进步功率;一同,即使是在单cpu的机器上,选用多线程模型来规划程序,正如当年选用多进程模型替代单进程模型相同,使规划更简练、功用更齐备,程序的履行功率也更高,例如选用多个线程呼应多个输入,而此刻多线程模型所完结的功用实际上也能够用多进程模型来完结,而与后者比较,线程的上下文切换开支就比进程要小多了,从语义上来说,一同呼应多个输入这样的功用,实际上便是同享了除cpu以外的全部资源的。
针对线程模型的两大含义,别离开发出了中心级线程和用户级线程两种线程模型,分类的规范首要是线程的调度者在核内仍是在核外。前者更利于并发运用多处理器的资源,而后者则更多考虑的是上下文切换开支。在现在的商用体系中,一般都将两者结合起来运用,既供给中心线程以满意smp体系的需求,也支撑用线程库的办法在用户态完结另一套线程机制,此刻一个中心线程一同成为多个用户态线程的调度者。正如许多技能相同,“混合”一般都能带来更高的功率,但一同也带来更大的完结难度,出于“简略”的规划思路,Linux从一开端就没有完结混合模型的方案,但它在完结上选用了另一种思路的“混合”。
在线程机制的详细完结上,能够在操作体系内核上完结线程,也能够在核外完结,后者明显要求核内至少完结了进程,而前者则一般要求在核内一同也支撑进程。中心级线程模型明显要求前者的支撑,而用户级线程模型则不必定依据后者完结。这种差异,正如前所述,是两种分类办法的规范不同带来的。
当核内既支撑进程也支撑线程时,就能够完结线程-进程的“多对多”模型,即一个进程的某个线程由核内调度,而一同它也能够作为用户级线程池的调度者,挑选适宜的用户级线程在其空间中运转。这便是前面说到的“混合”线程模型,既可满意多处理机体系的需求,也能够最大极限的减小调度开支。绝大多数商业操作体系(如Digital Unix、Solaris、Irix)都选用的这种能够彻底完结POSIX1003.1c规范的线程模型。在核外完结的线程又能够分为“1对1”、“多对一”两种模型,前者用一个中心进程(也许是轻量进程)对应一个线程,将线程调度等同于进程调度,交给中心完结,而后者则彻底在核外完结多线程,调度也在用户态完结。后者便是前面说到的单纯的用户级线程模型的完结办法,明显,这种核外的线程调度器实际上只需求完结线程运转栈的切换,调度开支十分小,但一同因为中心信号(不管是同步的仍是异步的)都是以进程为单位的,因而无法定位到线程,所以这种完结办法不能用于多处理器体系,而这个需求正变得越来越大,因而,在实际中,纯用户级线程的完结,除算法研讨意图以外,简直现已消失了。
Linux内核只供给了轻量进程的支撑,约束了更高效的线程模型的完结,但Linux侧重优化了进程的调度开支,必定程度上也弥补了这一缺陷。现在最盛行的线程机制LinuxThreads所选用的便是线程-进程“1对1”模型,调度交给中心,而在用户级完结一个包含信号处理在内的线程办理机制。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 CSIGNAL0x000000ff/* signal mask to be sent at exit */#define CLONE_VM0x00000100/* 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_SIGHAND0x00000800/* set if signal handlers and blocked signals shared */#define CLONE_PID0x00001000/* set if pid shared */#define CLONE_PTRACE0x00002000/* set if we want to let tracing continue on the child too */#define CLONE_VFORK0x00004000/* set if the parent wants the child to wake it up on mm_release */#define CLONE_PARENT0x00008000/* set if we want to have the same parent as the cloner */#define CLONE_THREAD0x00010000/* Same thread group? */#define CLONE_NEWNS0x00020000/* 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对1”线程模型,一个线程实体对应一个中心轻量级进程,而线程之间的办理在核外函数库中完结。
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数组将如下图所示:
新创立的线程将首先在__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.办理线程
“1对1”模型的优点之一是线程的调度由中心完结了,而其他比方线程撤销、线程间的同步等作业,都是在核外线程库中完结的。在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)
初始化完毕后,在__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.线程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的“1对1”模型。
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,这在现在的“1对1”模型下是无法完结的。
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在实时性上考虑都很少。
四。其他的线程完结机制
LinuxThreads的问题,特别是兼容性上的问题,严峻阻止了Linux上的跨渠道运用(如Apache)选用多线程规划,然后使得Linux上的线程运用一向保持在比较低的水平。在Linux社区中,现已有许多人在为改善线程功能而尽力,其间既包含用户级线程库,也包含中心级和用户级合作改善的线程库。现在最为人看好的有两个项目,一个是RedHat公司牵头研制的NPTL(Native Posix Thread Library),另一个则是IBM出资开发的NGPT(Next Generation Posix Threading),二者都是环绕彻底兼容POSIX 1003.1c,一同在核内和核外做作业以而完结多对多线程模型。这两种模型都在必定程度上弥补了LinuxThreads的缺陷,且都是重起炉灶全新规划的。
1.NPTL
NPTL的规划方针概括可概括为以下几点:
POSIX兼容性
SMP结构的运用
低发动开支
低链接开支(即不运用线程的程序不应当受线程库的影响)
与LinuxThreads运用的二进制兼容性
软硬件的可扩展才能
多体系结构支撑
NUMA支撑
与C++集成
在技能完结上,NPTL依然选用1:1的线程模型,并合作glibc和最新的Linux Kernel2.5.x开发版在信号处理、线程同步、存储办理等多方面进行了优化。和LinuxThreads不同,NPTL没有运用办理线程,中心线程的办理直接放在核内进行,这也带了功能的优化。
首要是因为中心的问题,NPTL依然不是100%POSIX兼容的,但就功能而言相对LinuxThreads现已有很大程度上的改善了。
2.NGPT
IBM的开放源码项目NGPT在2003年1月10日推出了安稳的2.2.0版,但相关的文档作业还差许多。就现在所知,NGPT是依据GNU Pth(GNU Portable Threads)项目而完结的M:N模型,而GNU Pth是一个经典的用户级线程库完结。
依照2003年3月NGPT官方网站上的告诉,NGPT考虑到NPTL日益广泛地为人所承受,为防止不同的线程库版别引起的紊乱,往后将不再进跋涉一步开发,当今进行支撑性的保护作业。也便是说,NGPT现已抛弃与NPTL竞赛下一代Linux POSIX线程库规范。
3.其他高效线程机制
此处不能不说到Scheduler Activations。这个1991年在ACM上宣布的多线程内核结构影响了许多多线程内核的规划,其间包含Mach3.0、NetBSD和商业版别Digital Unix(现在叫Compaq True64 Unix)。它的本质是在运用用户级线程调度的一同,尽或许地削减用户级对中心的体系调用恳求,而后者往往是运转开支的重要来历。选用这种结构的线程机制,实际上是结合了用户级线程的灵敏高效和中心级线程的实用性,因而,包含Linux、FreeBSD在内的多个开放源码操作体系规划社区都在进行相关研讨,力求在本体系中完结Scheduler Activations。