本博客前面部分转自:http://c.biancheng.net/cpp/html/2593.html
线程的基本概念
线程最直接的理解就是“轻量级进程”,它是一个基本的CPU执行单元,也是程序执行流的最小单元,由线程ID、程序计数器、寄存器集合和堆栈组成。线程是进程中的一个实体,是被系统独立调度和分派的基本单位,线程自己不拥有系统资源,只拥有一点在运行中必不可少的资源,但它可与同属一个进程的其他线程共享进程所拥有的全部资源。一个线程可以创建和撤销另一个线程,同一进程中的多个线程之间可以并发执行。由于线程之间的相互制约,致使线程在运行中呈现出间断性。线程也有就绪、阻塞和运行三种基本状态。
引入线程后,进程的内涵发生了改变,进程只作为除CPU以外系统资源的分配单元,线程则作为处理机的分配单元。
线程与进程的比较
2) 拥有资源。不论是传统操作系统还是设有线程的操作系统,进程都是拥有资源的基本单位,而线程不拥有系统资源(也有一点必不可少的资源),但线程可以访问其隶属进程的系统资源。
3) 并发性。在引入线程的操作系统中,不仅进程之间可以并发执行,而且多个线程之间也可以并发执行,从而使操作系统具有更好的并发性,提高了系统的吞吐量。
4) 系统开销。由于创建或撤销进程时,系统都要为之分配或回收资源,如内存空间、 I/O设备等,因此操作系统所付出的开销远大于创建或撤销线程时的开销。类似地,在进行进程切换时,涉及当前执行进程CPU环境的保存及新调度到进程CPU环境的设置,而线程切换时只需保存和设置少量寄存器内容,开销很小。此外,由于同一进程内的多个线程共享进程的地址空间,因此,这些线程之间的同步与通信非常容易实现,甚至无需操作系统的干预。
5) 地址空间和其他资源(如打开的文件):进程的地址空间之间互相独立,同一进程的各线程间共享进程的资源,某进程内的线程对于其他进程不可见。
6) 通信方面:进程间通信(IPC)需要进程同步和互斥手段的辅助,以保证数据的一致性,而线程间可以直接读/写进程数据段(如全局变量)来进行通信。
线程的属性
在多线程操作系统中,把线程作为独立运行(或调度)的基本单位,此时的进程,已不再是一个基本的可执行实体。但进程仍具有与执行相关的状态,所谓进程处于“执行”状态,实际上是指该进程中某线程正在执行。线程的主要属性如下:
- 线程是一个轻型实体,它不拥有系统资源,但每个线程都应有一个唯一的标识符和一个线程控制块,线程控制块记录了线程执行的寄存器和栈等现场状态。
- 不同的线程可以执行相同的程序,即同一个服务程序被不同的用户调用时,操作系统为它们创建成不同的线程。
- 同一进程中的各个线程共享该进程所拥有的资源。
- 线程是处理机的独立调度单位,多个线程是可以并发执行的。在单CPU的计算机系统中,各线程可交替地占用CPU;在多CPU的计算机系统中,各线程可同时占用不同的CPU,若各个CPU同时为一个进程内的各线程服务则可缩短进程的处理时间。
- —个线程被创建后便开始了它的生命周期,直至终止,线程在生命周期内会经历阻塞态、就绪态和运行态等各种状态变化。
线程的实现方式
线程的实现可以分为两类:用户级线程(User-LevelThread, ULT)和内核级线程(Kemel-LevelThread, KLT)。内核级线程又称为内核支持的线程。
在用户级线程中,有关线程管理的所有工作都由应用程序完成,内核意识不到线程的存在。应用程序可以通过使用线程库设计成多线程程序。通常,应用程序从单线程起始,在该线程中开始运行,在其运行的任何时刻,可以通过调用线程库中的派生例程创建一个在相同进程中运行的新线程。图2-2(a)说明了用户级线程的实现方式。
在内核级线程中,线程管理的所有工作由内核完成,应用程序没有进行线程管理的代码,只有一个到内核级线程的编程接口。内核为进程及其内部的每个线程维护上下文信息,调度也是在内核基于线程架构的基础上完成。图2-2(b)说明了内核级线程的实现方式。
在一些系统中,使用组合方式的多线程实现。线程创建完全在用户空间中完成,线程的调度和同步也在应用程序中进行。一个应用程序中的多个用户级线程被映射到一些(小于或等于用户级线程的数目)内核级线程上。图2-2(c)说明了用户级与内核级的组合实现方式。
多线程模型
有些系统同时支持用户线程和内核线程由此产生了不同的多线程模型,即实现用户级线程和内核级线程的连接方式。
1) 多对一模型(many2one)
将多个用户级线程映射到一个内核级线程,线程管理在用户空间完成。
此模式中,用户级线程对操作系统不可见(即透明)。
优点:线程管理是在用户空间进行的,因而效率比较高。
缺点:当一个线程在使用内核服务时被阻塞,那么整个进程都会被阻塞;多个线程不能并行地运行在多处理机上。
该模型完全在核外实现多线程,调度也在用户态完成。后者就是前面提到的单纯的用户级线程模型的实现方式,显然,这种核外的线程调度器实际上只需要完成线程运行栈的切换,调度开销非常小,但同时因为核心信号(无论是同步的还是异步的)都是以进程为单位的,因而无法定位到线程,所以这种实现方式不能用于多处理器系统,而这个需求正变得越来越大,因此,在现实中,纯用户级线程的实现,除算法研究目的以外,几乎已经消失了。
2) 一对一模型(one2one)
将每个用户级线程映射到一个内核级线程。
优点:当一个线程被阻塞后,允许另一个线程继续执行,所以并发能力较强。
缺点:每创建一个用户级线程都需要创建一个内核级线程与其对应,这样创建线程的开销比较大,会影响到应用程序的性能。
3) 多对多模型(many2many)
将 n 个用户级线程映射到 m 个内核级线程上,要求 m <= n。
特点:在多对一模型和一对一模型中取了个折中,克服了多对一模型的并发度不高的缺点,又克服了一对一模型的一个用户进程占用太多内核级线程,开销太大的缺点。又拥有多对一模型和一对一模型各自的优点,可谓集两者之所长。
Linux线程的发展历史
轻量级进程LWP
在大多数系统中,LWP与普通进程的区别也在于它只有一个最小的执行上下文和调度程序所需的统计信息,而这也是它之所以被称为轻量级的原因。
因为LWP之间共享它们的大部分资源,所以它在某些应用程序就不适用了;这个时候就要使用多个普通的进程了。例如,为了避免内存泄漏(a process can be replaced by another one)和实现特权分隔(processes can run under other credentials and have other permissions)。
由于ULT的线程是在用户态,对应的内核部分还是一个进程,因此ULT就没有办法利用多处理器的优势,而KLT就可以通过调度将线程分布在多个处理上运行,这样KLT的性能高得多;另外,一个ULT的线程阻塞,所有的线程都阻塞,而KLT一个线程阻塞不会影响其它线程。
其实最初根本没有线程的概念,只有进程,一个任务一个进程一个执行流,多任务处理机就是多进程。后来提出线程的概念,但是要如何去实现,这里就有很多种实现方法了,文章看到这里,可以想到两种实现方法,一种就是上面所说的用户线程的方法,其优缺点上文以简述;再有就是用轻量级进程去模拟,即我们可以把LWP看成是一个线程。就应为这个使得线程和进程的概念混淆了,至少我觉得很多人其实根本就不知道,至少我以前不知道,有人说系统调度单位是进程,又有人说是线程,其实系统调度的单位一直就没有改变,只是后来部分线程和进程的界限模糊了,至少上文中的用户线程绝对不是调度对象,LWP模拟的线程却是调度对象。
- 查看进程列表的时候, 相关的一组task_struct应当被展现为列表中的一个节点;
- 发送给这个"进程"的信号(对应kill系统调用), 将被对应的这一组task_struct所共享, 并且被其中的任意一个"线程"处理;
- 发送给某个"线程"的信号(对应pthread_kill), 将只被对应的一个task_struct接收, 并且由它自己来处理;
- 当"进程"被停止或继续时(对应SIGSTOP/SIGCONT信号), 对应的这一组task_struct状态将改变;
- 当"进程"收到一个致命信号(比如由于段错误收到SIGSEGV信号), 对应的这一组task_struct将全部退出;
- 等等(以上可能不够全);
模型一 :LinuxThreads(已淘汰,了解即可)
接下来要说说, 为什么A程序创建了10个线程, 但是ps时却会出现11个A进程了. 因为linuxthreads自动创建了一个管理线程. 上面提到的"第5点"就是靠管理线程来实现的.
可见, 线程的创建与销毁都是通过管理线程来完成的, 于是管理线程就成了linuxthreads的一个性能瓶颈.
创建与销毁需要一次进程间通信, 一次上下文切换之后才能被管理线程执行, 并且多个请求会被管理线程串行地执行.
按照POSIX的定义,同一进程的所有的线程应该共享同一个进程和父进程ID,而Linux的这种LWP方式显然不能满足这一点。
异步信号是以进程为单位分发的,而Linux的线程本质上每个都是一个进程,且没有进程组的概念,所以某些缺省信号难以做到对所有线程有效,例如SIGSTOP和SIGCONT,就无法将整个进程挂起,而只能将某个线程挂起。
LinuxThreads将每个进程的线程最大数目定义为1024,但实际上这个数值还受到整个系统的总进程数限制,这又是由于线程其实是核心进程。
管理线程容易成为瓶颈,这是这种结构的通病;同时,管理线程又负责用户线程的清理工作,因此,尽管管理线程已经屏蔽了大部分的信号,但一旦管理线程死亡,用户线程就不得不手工清理了,而且用户线程并不知道管理线程的状态,之后的线程创建等请求将无人处理。
LinuxThreads中的线程同步很大程度上是建立在信号基础上的,这种通过内核复杂的信号处理机制的同步方式,效率一直是个问题。
7)实时性问题
线程的引入有一定的实时性考虑,但LinuxThreads暂时不支持,比如调度选项,目前还没有实现。不仅LinuxThreads如此,标准的Linux在实时性上考虑都很少
模型二:NPTL(当前使用标准)
本质上来说,NPTL还是一个LWP的实现机制,但相对原有LinuxThreads来说,做了很多的改进。下面我们看一下NPTL如何解决原有LinuxThreads实现机制的缺陷
在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中, 只能由本线程来处理.