(一)进程定义
OS系统从只能跑一个程序到能跑多个。进程可以描述程序的执行过程。
进程:一个具有一定独立功能的程序在一个数据集合上的一次动态执行过程。
只有当一个程序被OS加载到内存中,cpu对其执行时,这个过程是动态的,称为进程。
(二)进程的组成
包含了正在运行的一个程序的所有状态信息
- 程序的代码
- 程序处理的数据
- 要知道现在执行哪条指令,程序计数器中的值指示将运行的指令。
- CPU寄存器会动态变化,一组通用寄存器的当前值,堆,栈等;
- 各种系统资源,内存,外存,网络
(1)进程与程序的联系
程序是进程的基础,代码控制操作,可以多次执行程序,每次构成不同的进程;进程是程序功能的体现;多次执行——某一个程序对应多个进程;调用关系——某一个进程包括多个程序
多对多的映射关系
(2)进程与程序的区别
程序静态,有序代码的集合;进程动态,执行中可以是核心态/用户态,写的代码都是用户态,但有些操作比如读写文件只能由OS完成,
OS代表进程在内核中执行,此时为核心态;
进程是暂时的,是状态变化的过程,程序永久;
组成不同,进程包括程序,数据(可能变化),进程控制块(进程状态信息)
(三)进程的特点
- 动态性:
- 并发性:在一段时间内有多个程序在执行,不同于并行,是一个时间点有多个在跑,需多个CPU即多核,进程可以被独立调度并占用处理机运行
- 独立性:正确性不受影响(通过OS给不同的进程分配不同页表);
- 制约性:因访问共享数据/资源或进程间同步产生制约,要同步互斥;
描述进程的数据结构:
- 进程控制块,PROCESS control block PCB
- OS给每个进程都维护了一个PCB,保存与之有关的所有状态信息。
(四)进程控制结构
(1)PCB进程控制块:
进程存在的唯一标识,操作系统管理控制进程运行所用的信息集合,描述进程的基本情况和运行变化的过程。用PCB的生成,回收,组织管理来完成进程的创建、终止和管理。
(2)PCB含有三大类信息:
(a)进程标识,哪个程序在执行,执行了几次(本进程的标识),产生者标识(父进程标识),用户标识
(b)处理机状态信息保存区,主要就是寄存器,保存进程的运行现场信息:
-
- 用户可见寄存器,程序使用的数据,地址
- 控制和状态寄存器,程序计数器pc,程序状态字PSW
- 栈指针,过程调用/系统调用/中断处理和返回时需要用到
(c)进程控制信息
-
- 调度和状态信息,用于操作系统调度进程并占用处理机使用。运行状态?等待?进程当前的执行现状
- 进程间通信信息,各种标识、信号、信件等
- 进程本身的存储管理信息,即指向本进程映像存储空间的数据结构,内存信息,占了多少?要不要回收?
- 进程所用资源,打开使用的系统资源,如文件
- 有关数据结构连接信息,父进程,子进程,构成一个链,进程可以连接到一个进程队列,或链接到其他进程的PCB
(3)PCB的组织方式:
链表(便于插删,用于通用的OS):同一状态的进程PCB为一链表,多个状态对应更多个不同的链表,就绪链表,阻塞链表
索引表(数组,不利于插删,使用于固定数目的进程,相对创建更快捷):同一状态的归入一个index表(每一个index指向PCB),就绪/阻塞索引表
(五)进程状态
进程生命期管理:创建—>运行—>等待—>唤醒—>结束
创建的三个时间点
- 系统初始化时,创建INIT进程,
- INIT再负责创建其他进程;
- 用户请求创建一个NEW PROCESS,正在运行的进程执行了创建进程的系统调用。
运行:
- 内核选择一个就绪的进程,让它占用处理机(cpu)并执行
等待(阻塞)的三种情况:
- 请求并等待系统服务,无法马上完成;
- 启动某种操作(和其他进程协调工作),无法马上完成;需要的数据没有到达
- 进程自己触发阻塞,因为只有自己才知道何时需要等待某事件
唤醒的情况:
- 需要的资源可被满足,等待的事件到达,都意味着可将该进程的PCB插入到就绪队列,因为自身没有占用cpu执行,所以只能被OS或其他进程唤醒
结束的情形:
- 自愿(正常退出,错误退出),强制性的(致命错误,被其他进程所杀)
(六)进程状态变化模型
结束前的三种状态:
- 运行态(running),
- 就绪态(ready 获得除CPU即处理机之外的一切资源,一旦得到CPU就可以运行),
- 等待状态(阻塞态,blocked)
进程状态变化模型
此外还有五态模型:
- 创建态(new) 已创建还没就绪
- 结束态(exit) 正在从OS中消失,PCB还存在
- running to ready:分时-时间片到了,就切到另一个程序,这个转变为ready,由OS结合时钟完成。
- new->ready:很快,只是生成pcb
(七)进程挂起
不同于进程阻塞。挂起时没有占用该内存空间,而是映像在磁盘上。类似虚存中,有的程序段被放到了硬盘上。
Suspend:把一个进程从内存转到外存。
可以合理、充分地利用系统资源。
两种挂起状态:
- 阻塞挂起,进程在外存等事件blocked suspend
- 就绪挂起,进程在外存,但进了内存就能执行ready suspend
状态转换:
在内存中被挂起:
-
- blocked to blocked suspend: 没有进程ready或ready进程要求更多内存时挂起,以提交新进程或运行就绪进程
- ready to ready suspend: 多个就绪进程挂优先级低的,有高优先级阻塞(OS认为会很快就绪)进程和低优先ready进程时,挂低优先的就绪进程
- running to ready blocked: 抢先式分时系统,空间不够或高优先级阻塞挂起进程进入就绪挂起时,正在运行的进程被就绪挂起
外存中:
-
- blocked suspend to ready suspend: 相关事件出现时,转变为ready suspend但还在硬盘上。
- 解挂/激活 activate:外存到内存,需要运行该进程时
- ready suspend to ready: 没有就绪进程或挂起就绪优先进程优先级高于当前就绪进程时
- blocked suspend to blocked:当一个进程释放了足够内存时,OS把高优先级阻塞挂起(认为很快出现等待的事件)转换为阻塞进程
OS怎么通过PCB和定义的进程状态来管理?
以进程为基本结构的OS,底层为CPU调度程序(执行哪个?中断处理等),上面是各种进程。
OS要维护一组状态队列(重要的数据结构),表示系统中所有进程的当前状态。
就绪队列,各种类型的阻塞队列,挂起队列
PCB根据状态排入相应队列,状态变化加入和脱离队列
(八)线程(thread)管理
更小的能独立运行的基本单位——线程
单进程实现方式:
- 如果CPU能力不够强,排在前面的函数太慢,各个函数之间不能并发,影响实现效率。
多进程的变化方法:
- 拆开函数,并发,轮着走。进程间通信,共享数据?维护进程的OS开销大,创建,切换,都要保存回复当前进程的状态信息
- 提出一种新的实体,满足实体间可以并发执行,共享相同的地址空间。而不是像进程,需要OS帮助通信和交换信息。
什么是线程
- thread: 进程中的一条执行流程
重新理解进程,由两部分功能组成:
- 资源管理,包括地址空间(代码段,数据段),打开的文件等的资源平台(环境)等
- 从运行的角度,线程,即代码在这个资源平台上的一条执行流程
线程:
- 一个进程所拥有的线程共用进程的资源平台,利于通信。
- 线程有自己的TCB,thread control block, 只负责这条流程的信息,包括PC程序计数器,SP堆栈,State状态,和寄存器。
- 有不同的控制流,需要不同的寄存器来表示控制流的执行状态,每个线程有独立的这些信息,但共享一个资源。
- 线程=进程-共享资源
- 线程是控制流,一个进程中可以同时存在多个线程,各个线程并发执行,共享地址空间和文件等资源。
- 如果一个线程写错了,崩溃,如破坏了数据,会导致这个进程的所有线程都崩溃。
适用范围:
- 高精计算中比如天气预报,代码相对统一,对性能要求高,用线程;网络有关如开一个浏览器的N个页面,一个页面有问题所有的都关闭了,现在都改成了用进程。如Chrome,用的就是一个进程打开一个网页。
- 多线程,在进程空间内有多个控制流且执行流程不一样,有各自独立的寄存器和堆栈,但共享代码段,数据段,资源。
线程与进程的比较:
- 进程是资源分配单元(内存,打开的文件,访问的网络),线程是CPU调度单位,CPU也是一种特殊的资源,要执行控制流需要的相关信息
- 进程拥有一个完整资源平台,而线程只独享必不可少的资源如寄存器和栈
- 线程同样具有就绪,阻塞和执行三种基本状态和转换关系
- 线程能减少并发执行的时空开销:
- 线程的创建时间、终止时间、同一进程内线程的切换时间都更短,因为进程要创建一些对内存和打开的文件的管理信息,而线程可以直接用所属的进程的信息,因为同一进程内的线程有同一个地址空间,同一个页表,所有信息可以重用,无失效处理。而进程要切页表,开销大,访问的地址空间不一样,cache,TLB等硬件信息的访问开销大。另外线程的数据传递不用通过内核,直接通过内存地址可以访问到,效率很高。
(十)线程的实现
三种实现方式:
- 用户线程,在用户空间实现,OS看不到,由应用程序的用户线程库来管理;POSIX Pthreads, Mach C-threads
- 内核线程,在内核中实现,OS管理的;Windows
- 轻量级进程lightweight process:内核中实现,支持用户线程。Solaris, Linux
用户线程与内核线程间,可以是多对一,一对一,多对多。
(1)用户线程
在用户空间实现的线程机制,不依赖于OS内核,有一组用户级的线程库函数来完成线程的管理,包括创建、终止、同步和调度等。
用户态线程的优缺点:
优点:
- OS只能看到线程所属的进程,可用不支持线程技术的多进程OS,每个进程都有私有的TCB列表来跟踪记录各个线程的状态信息(PC, 栈指针,寄存器),TCB由线程库函数来维护;切换线程也由线程库函数完成,无用户态/核心态切换,速度快;每个进程由自定义的线程调度算法。灵活。
缺点:
- 一个线程发起系统调用而阻塞,那整个进程都在等待(OS只能判断进程);一个线程开始运行后,除非主动交出CPU使用权,否则所在进程中的其他线程都无法运行;由于时间片分给进程,多线程时时间片更少会慢。
(2)内核线程
内核线程,OS内核来完成线程的创建终止和管理。OS的调度单位不再是进程而是线程。进程主要完成资源的管理。
内核维护PCB和TCB,PCB维护了一系列的TCB,具体调度是TCB完成,只要完成一次切换(或创建,终止)就要完成一次用户态到内核态的变化,系统调用/调用内核函数,OS开销比用户线程大。在一个进程中,某个内核线程阻塞不影响其他线程运行;
时间片分给线程,多线程的进程自动获得更多CPU时间。Windows NT/2000/XP
(3)轻量级进程
内核支持的用户线程。一个进程有一个或多个轻量级进程,每个轻量级进程由一个单独的内核线程来支持。
(十一)进程的上下文切换 context switch
进程共享CPU,停止当前进程,并调度其他进程的切换叫做上下文切换。
必须切换前存储上下文,切换后恢复让进程不知道被暂停过,必须快速(上下文切换频繁),因为有时间开销所以也要尽量避免
要存储哪些context?
寄存器(pc,sp,…),CPU状态等
进程执行中要关注寄存器,PC(进程执行到了什么地方),栈指针(调用关系,相应的局部变量位置)等。
这些信息要被保存到PCB中,进程挂起时要把PCB的这些值重置,恢复到寄存器中去,使接下来进程可以继续在CPU上执行。
上下文切换的开销越小越好,且所有信息都与硬件紧密相连,所以OS中实现是用汇编代码。
需要知道哪些进程能切换?
PC为活跃进程准备了进程控制块PCB,OS将不同PCB放在不同的状态队列链表里便于选择
- 就绪队列
- 等待I/O队列—-分为每个设备的队列
- 僵尸队列
文章二:Linux 线程实现机制分析
一.基础知识:线程和进程
按照教科书上的定义,进程是资源管理的最小单位,线程是程序执行的最小单位。在操作系统设计上,从进程演化出线程,最主要的目的就是更好的支持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()系统调用的最终实现:
1 2 3 |
|
其中的clone_flags取自以下宏的"或"值:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
在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 ([email protected])负责开发完成,并已绑定在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数组将如下图所示:
新创建的线程将首先在__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)
初始化结束后,在__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遵循以下公式:
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的处理是这样的:
1 2 3 4 |
|
这段代码表明,目前的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()函数中:
1 |
|
在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()中继续指定:
1 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。
文章三 Linux 线程模型的比较:LinuxThreads 和 NPTL
当 Linux 最初开发时,在内核中并不能真正支持线程。但是它的确可以通过 clone()
系统调用将进程作为可调度的实体。这个调用创建了调用进程(calling process)的一个拷贝,这个拷贝与调用进程共享相同的地址空间。LinuxThreads 项目使用这个调用来完全在用户空间模拟对线程的支持。不幸的是,这种方法有一些缺点,尤其是在信号处理、调度和进程间同步原语方面都存在问题。另外,这个线程模型也不符合 POSIX 的要求。
要改进 LinuxThreads,非常明显我们需要内核的支持,并且需要重写线程库。有两个相互竞争的项目开始来满足这些要求。一个包括 IBM 的开发人员的团队开展了 NGPT(Next-Generation POSIX Threads)项目。同时,Red Hat 的一些开发人员开展了 NPTL 项目。NGPT 在 2003 年中期被放弃了,把这个领域完全留给了 NPTL。
尽管从 LinuxThreads 到 NPTL 看起来似乎是一个必然的过程,但是如果您正在为一个历史悠久的 Linux 发行版维护一些应用程序,并且计划很快就要进行升级,那么如何迁移到 NPTL 上就会变成整个移植过程中重要的一个部分。另外,我们可能会希望了解二者之间的区别,这样就可以对自己的应用程序进行设计,使其能够更好地利用这两种技术。
本文详细介绍了这些线程模型分别是在哪些发行版上实现的。
(一)LinuxThreads 设计细节
线程 将应用程序划分成一个或多个同时运行的任务。线程与传统的多任务进程 之间的区别在于:线程共享的是单个进程的状态信息,并会直接共享内存和其他资源。同一个进程中线程之间的上下文切换通常要比进程之间的上下文切换速度更快。因此,多线程程序的优点就是它可以比多进程应用程序的执行速度更快。另外,使用线程我们可以实现并行处理。这些相对于基于进程的方法所具有的优点推动了 LinuxThreads 的实现。
LinuxThreads 最初的设计相信相关进程之间的上下文切换速度很快,因此每个内核线程足以处理很多相关的用户级线程。这就导致了一对一 线程模型的革命。
让我们来回顾一下 LinuxThreads 设计细节的一些基本理念:
-
LinuxThreads 非常出名的一个特性就是管理线程(manager thread)。管理线程可以满足以下要求:
- 系统必须能够响应终止信号并杀死整个进程。
- 以堆栈形式使用的内存回收必须在线程完成之后进行。因此,线程无法自行完成这个过程。
- 终止线程必须进行等待,这样它们才不会进入僵尸状态。
- 线程本地数据的回收需要对所有线程进行遍历;这必须由管理线程来进行。
- 如果主线程需要调用
pthread_exit()
,那么这个线程就无法结束。主线程要进入睡眠状态,而管理线程的工作就是在所有线程都被杀死之后来唤醒这个主线程。
- 为了维护线程本地数据和内存,LinuxThreads 使用了进程地址空间的高位内存(就在堆栈地址之下)。
- 原语的同步是使用信号 来实现的。例如,线程会一直阻塞,直到被信号唤醒为止。
- 在克隆系统的最初设计之下,LinuxThreads 将每个线程都是作为一个具有惟一进程 ID 的进程实现的。
- 终止信号可以杀死所有的线程。LinuxThreads 接收到终止信号之后,管理线程就会使用相同的信号杀死所有其他线程(进程)。
- 根据 LinuxThreads 的设计,如果一个异步信号被发送了,那么管理线程就会将这个信号发送给一个线程。如果这个线程现在阻塞了这个信号,那么这个信号也就会被挂起。这是因为管理线程无法将这个信号发送给进程;相反,每个线程都是作为一个进程在执行。
- 线程之间的调度是由内核调度器来处理的。
(二)LinuxThreads 及其局限性
LinuxThreads 的设计通常都可以很好地工作;但是在压力很大的应用程序中,它的性能、可伸缩性和可用性都会存在问题。下面让我们来看一下 LinuxThreads 设计的一些局限性:
- 它使用管理线程来创建线程,并对每个进程所拥有的所有线程进行协调。这增加了创建和销毁线程所需要的开销。
- 由于它是围绕一个管理线程来设计的,因此会导致很多的上下文切换的开销,这可能会妨碍系统的可伸缩性和性能。
- 由于管理线程只能在一个 CPU 上运行,因此所执行的同步操作在 SMP 或 NUMA 系统上可能会产生可伸缩性的问题。
- 由于线程的管理方式,以及每个线程都使用了一个不同的进程 ID,因此 LinuxThreads 与其他与 POSIX 相关的线程库并不兼容。
- 信号用来实现同步原语,这会影响操作的响应时间。另外,将信号发送到主进程的概念也并不存在。因此,这并不遵守 POSIX 中处理信号的方法。
- LinuxThreads 中对信号的处理是按照每线程的原则建立的,而不是按照每进程的原则建立的,这是因为每个线程都有一个独立的进程 ID。由于信号被发送给了一个专用的线程,因此信号是串行化的 —— 也就是说,信号是透过这个线程再传递给其他线程的。这与 POSIX 标准对线程进行并行处理的要求形成了鲜明的对比。例如,在 LinuxThreads 中,通过
kill()
所发送的信号被传递到一些单独的线程,而不是集中整体进行处理。这意味着如果有线程阻塞了这个信号,那么 LinuxThreads 就只能对这个线程进行排队,并在线程开放这个信号时在执行处理,而不是像其他没有阻塞信号的线程中一样立即处理这个信号。 - 由于 LinuxThreads 中的每个线程都是一个进程,因此用户和组 ID 的信息可能对单个进程中的所有线程来说都不是通用的。例如,一个多线程的
setuid()
/setgid()
进程对于不同的线程来说可能都是不同的。 - 有一些情况下,所创建的多线程核心转储中并没有包含所有的线程信息。同样,这种行为也是每个线程都是一个进程这个事实所导致的结果。如果任何线程发生了问题,我们在系统的核心文件中只能看到这个线程的信息。不过,这种行为主要适用于早期版本的 LinuxThreads 实现。
- 由于每个线程都是一个单独的进程,因此 /proc 目录中会充满众多的进程项,而这实际上应该是线程。
- 由于每个线程都是一个进程,因此对每个应用程序只能创建有限数目的线程。例如,在 IA32 系统上,可用进程总数 —— 也就是可以创建的线程总数 —— 是 4,090。
- 由于计算线程本地数据的方法是基于堆栈地址的位置的,因此对于这些数据的访问速度都很慢。另外一个缺点是用户无法可信地指定堆栈的大小,因为用户可能会意外地将堆栈地址映射到本来要为其他目的所使用的区域上了。按需增长(grow on demand) 的概念(也称为浮动堆栈 的概念)是在 2.4.10 版本的 Linux 内核中实现的。在此之前,LinuxThreads 使用的是固定堆栈。
(三)关于 NPTL
NPTL,或称为 Native POSIX Thread Library,是 Linux 线程的一个新实现,它克服了 LinuxThreads 的缺点,同时也符合 POSIX 的需求。与 LinuxThreads 相比,它在性能和稳定性方面都提供了重大的改进。与 LinuxThreads 一样,NPTL 也实现了一对一的模型。
Ulrich Drepper 和 Ingo Molnar 是 Red Hat 参与 NPTL 设计的两名员工。他们的总体设计目标如下:
- 这个新线程库应该兼容 POSIX 标准。
- 这个线程实现应该在具有很多处理器的系统上也能很好地工作。
- 为一小段任务创建新线程应该具有很低的启动成本。
- NPTL 线程库应该与 LinuxThreads 是二进制兼容的。注意,为此我们可以使用
LD_ASSUME_KERNEL
,这会在本文稍后进行讨论。 - 这个新线程库应该可以利用 NUMA 支持的优点。
(四)NPTL 的优点
与 LinuxThreads 相比,NPTL 具有很多优点:
- NPTL 没有使用管理线程。管理线程的一些需求,例如向作为进程一部分的所有线程发送终止信号,是并不需要的;因为内核本身就可以实现这些功能。内核还会处理每个线程堆栈所使用的内存的回收工作。它甚至还通过在清除父线程之前进行等待,从而实现对所有线程结束的管理,这样可以避免僵尸进程的问题。
- 由于 NPTL 没有使用管理线程,因此其线程模型在 NUMA 和 SMP 系统上具有更好的可伸缩性和同步机制。
- 使用 NPTL 线程库与新内核实现,就可以避免使用信号来对线程进行同步了。为了这个目的,NPTL 引入了一种名为 futex 的新机制。futex 在共享内存区域上进行工作,因此可以在进程之间进行共享,这样就可以提供进程间 POSIX 同步机制。我们也可以在进程之间共享一个 futex。这种行为使得进程间同步成为可能。实际上,NPTL 包含了一个
PTHREAD_PROCESS_SHARED
宏,使得开发人员可以让用户级进程在不同进程的线程之间共享互斥锁。 - 由于 NPTL 是 POSIX 兼容的,因此它对信号的处理是按照每进程的原则进行的;
getpid()
会为所有的线程返回相同的进程 ID。例如,如果发送了SIGSTOP
信号,那么整个进程都会停止;使用 LinuxThreads,只有接收到这个信号的线程才会停止。这样可以在基于 NPTL 的应用程序上更好地利用调试器,例如 GDB。 - 由于在 NPTL 中所有线程都具有一个父进程,因此对父进程汇报的资源使用情况(例如 CPU 和内存百分比)都是对整个进程进行统计的,而不是对一个线程进行统计的。
- NPTL 线程库所引入的一个实现特性是对 ABI(应用程序二进制接口)的支持。这帮助实现了与 LinuxThreads 的向后兼容性。这个特性是通过使用
LD_ASSUME_KERNEL
实现的,下面就来介绍这个特性。
(五)LD_ASSUME_KERNEL 环境变量
正如上面介绍的一样,ABI 的引入使得可以同时支持 NPTL 和 LinuxThreads 模型。基本上来说,这是通过 ld (一个动态链接器/加载器)来进行处理的,它会决定动态链接到哪个运行时线程库上。
举例来说,下面是 WebSphere® Application Server 对这个变量所使用的一些通用设置;您可以根据自己的需要进行适当的设置:
LD_ASSUME_KERNEL=2.4.19
:这会覆盖 NPTL 的实现。这种实现通常都表示使用标准的 LinuxThreads 模型,并启用浮动堆栈的特性。LD_ASSUME_KERNEL=2.2.5
:这会覆盖 NPTL 的实现。这种实现通常都表示使用 LinuxThreads 模型,同时使用固定堆栈大小。
我们可以使用下面的命令来设置这个变量:
export LD_ASSUME_KERNEL=2.4.19
注意,对于任何 LD_ASSUME_KERNEL
设置的支持都取决于目前所支持的线程库的 ABI 版本。例如,如果线程库并不支持 2.2.5 版本的 ABI,那么用户就不能将 LD_ASSUME_KERNEL
设置为 2.2.5。通常,NPTL 需要 2.4.20,而 LinuxThreads 则需要 2.4.1。
如果您正运行的是一个启用了 NPTL 的 Linux 发行版,但是应用程序却是基于 LinuxThreads 模型来设计的,那么所有这些设置通常都可以使用。
(六)GNU_LIBPTHREAD_VERSION 宏
大部分现代 Linux 发行版都预装了 LinuxThreads 和 NPTL,因此它们提供了一种机制来在二者之间进行切换。要查看您的系统上正在使用的是哪个线程库,请运行下面的命令:
$ getconf GNU_LIBPTHREAD_VERSION
这会产生类似于下面的输出结果:
NPTL 0.34
或者:
linuxthreads-0.10
(七)Linux 发行版所使用的线程模型、glibc 版本和内核版本
表 1 列出了一些流行的 Linux 发行版,以及它们所采用的线程实现的类型、glibc 库和内核版本。
线程实现 | C 库 | 发行版 | 内核 |
---|---|---|---|
LinuxThreads 0.7, 0.71 (for libc5) | libc 5.x | Red Hat 4.2 | |
LinuxThreads 0.7, 0.71 (for glibc 2) | glibc 2.0.x | Red Hat 5.x | |
LinuxThreads 0.8 | glibc 2.1.1 | Red Hat 6.0 | |
LinuxThreads 0.8 | glibc 2.1.2 | Red Hat 6.1 and 6.2 | |
LinuxThreads 0.9 | Red Hat 7.2 | 2.4.7 | |
LinuxThreads 0.9 | glibc 2.2.4 | Red Hat 2.1 AS | 2.4.9 |
LinuxThreads 0.10 | glibc 2.2.93 | Red Hat 8.0 | 2.4.18 |
NPTL 0.6 | glibc 2.3 | Red Hat 9.0 | 2.4.20 |
NPTL 0.61 | glibc 2.3.2 | Red Hat 3.0 EL | 2.4.21 |
NPTL 2.3.4 | glibc 2.3.4 | Red Hat 4.0 | 2.6.9 |
LinuxThreads 0.9 | glibc 2.2 | SUSE Linux Enterprise Server 7.1 | 2.4.18 |
LinuxThreads 0.9 | glibc 2.2.5 | SUSE Linux Enterprise Server 8 | 2.4.21 |
LinuxThreads 0.9 | glibc 2.2.5 | United Linux | 2.4.21 |
NPTL 2.3.5 | glibc 2.3.3 | SUSE Linux Enterprise Server 9 | 2.6.5 |
注意,从 2.6.x 版本的内核和 glibc 2.3.3 开始,NPTL 所采用的版本号命名约定发生了变化:这个库现在是根据所使用的 glibc 的版本进行编号的。
Java™ 虚拟机(JVM)的支持可能会稍有不同。IBM 的 JVM 可以支持表 1 中 glibc 版本高于 2.1 的大部分发行版。
(八)结束语
LinuxThreads 的限制已经在 NPTL 以及 LinuxThreads 后期的一些版本中得到了克服。例如,最新的 LinuxThreads 实现使用了线程注册来定位线程本地数据;例如在 Intel® 处理器上,它就使用了 %fs
和 %gs
段寄存器来定位访问线程本地数据所使用的虚拟地址。尽管这个结果展示了 LinuxThreads 所采纳的一些修改的改进结果,但是它在更高负载和压力测试中,依然存在很多问题,因为它过分地依赖于一个管理线程,使用它来进行信号处理等操作。
您应该记住,在使用 LinuxThreads 构建库时,需要使用 -D_REENTRANT
编译时标志。这使得库线程是安全的。
最后,也许是最重要的事情,请记住 LinuxThreads 项目的创建者已经不再积极更新它了,他们认为 NPTL 会取代 LinuxThreads。
LinuxThreads 的缺点并不意味着 NPTL 就没有错误。作为一个面向 SMP 的设计,NPTL 也有一些缺点。我曾经看到过在最近的 Red Hat 内核上出现过这样的问题:一个简单线程在单处理器的机器上运行良好,但在 SMP 机器上却挂起了。我相信在 Linux 上还有更多工作要做才能使它具有更好的可伸缩性,从而满足高端应用程序的需求。
文章内容来源于:
https://blog.csdn.net/github_36487770/article/details/60144610
https://www.ibm.com/developerworks/cn/linux/kernel/l-thread/index.html
https://www.ibm.com/developerworks/cn/linux/l-threading.html