• 进入"运维那点事"后,希望您第一件事就是阅读“关于”栏目,仔细阅读“关于Ctrl+c问题”,不希望误会!

Linux系统原理之线程管理

系统管理 彭东稳 9年前 (2015-09-01) 25549次浏览 已收录 0个评论

进程与线程

在现代操作系统中,进程支持多线程。进程是资源管理的最小单元;而线程是程序执行的最小单元。一个进程的组成实体可以分为两大部分:线程集合和资源集合。进程中的线程是动态的对象,代表了进程指令的执行。而资源包括地址空间、打开的文件、用户信息等等,由进程内的线程共享。线程有自己的私有数据:程序计数器,栈空间以及寄存器。

一个线程是一个单独的进程生成的一个执行单元。它与其他的线程并行地运行在同一个进程中。各个线程可以共享进程的资源,例如内存、地址空间、打开的文件等等。它们能访问相同的程序数据集。线程也被叫作轻量级的进程(Light Weight Process,LWP)。因为它们共享资源,所以每个线程不应该在同一时间改变它们共享的资源。互斥的实现、锁、序列化等是用户程序的责任。

从性能的角度来说,创建线程的开销比创建进程少,因数创建一个线程时不需要复制资源。另一方面,进程和线程拥在调度算法上有相似的特性。内核以相似的方式处理它们。下图展示了进程和线程各自的创建方式。

Linux系统原理之线程管理

传统进程的缺点

现实中有很多需要并发处理的任务,如数据库的服务器端、网络服务器、大容量计算等。一个任务是一个进程,传统的UNIX进程是单线程(执行流)的,单线程意味着程序必须是顺序执行,单个任务不能并发;既在一个时刻只能运行在一个处理器上,因此不能充分利用多处理器框架的计算机。如果采用多进程的方法,即把一个任务用多个进程解决,则有如下问题:

a. fork一个子进程的消耗是很大的,fork是一个昂贵的系统调用,即使使用现代的写时复制(copy-on-write)技术。

b. 各个进程拥有自己独立的地址空间,进程间的协作需要复杂的IPC技术,如消息传递和共享内存等。

大概说完进程后,我们就说说fork()函数了,当程序,更确切的说是进程执行了fork()系统调用,子进程会赋值父进程的所有内存页面,并将其载入操作系统为它所分配的那片独立内存中。不难想象,这个拷贝的动作将会非常耗时(相对于CPU来说)。但是Linux引入了一种机制COW(copy on write)写时拷贝,也就是当fork发生时,子进程根本不会去拷贝父进程的内存页面,而是与父进程都共享内存了,但是这就有一个麻烦,因为进程的特点是“资源独立”,子进程跟父进程都共享内存了,那不就成了线程了吗?对,Linux内核在2.6之前对于线程差不多就是这么干的。但是对于进程这么干不行,当子进程或父进程需要修改一个内存页面时,Linux就将这个内存页面赋值一份给修改者,然后再去修改,这样从用户的角度看,父子进程根本就没有共享什么内存。这个花招就是COW,也就是进程要写共享的内存页面时,先复制在改写。采用了COW技术之后,fork时,子进程还需要拷贝父进程的页面表。采用这种设计就是要模拟传统unix系统fork时的效果。当然,这种拷贝的代价非常的小。

多线程的优缺点

线程:其实可以先简单理解成cpu的一个执行流,指令序列。多支持多线程的程序(进程)可以取得真正的并行(parallelism),且由于共享进程的代码和全局数据,故线程间的通信是方便的。它的缺点也是由于线程共享进程的地址空间,因此可能会导致竞争,因此对某一块有多个线程要访问的数据需要一些同步技术。

轻量级进程LWP

既然称作轻量级进程,可见其本质仍然是进程,与普通进程相比,LWP与其它进程共享所有(或大部分)逻辑地址空间和系统资源,一个进程可以创建多个LWP,这样它们共享大部分资源;LWP有它自己的进程标识符,并和其他进程有着父子关系;这是和类Unix操作系统的系统调用vfork()生成的进程一样的。LWP由内核管理并像普通进程一样被调度。Linux内核是支持LWP的典型例子。Linux内核在 2.0.x版本就已经实现了轻量进程,应用程序可以通过一个统一的clone()系统调用接口,用不同的参数指定创建轻量进程还是普通进程,通过参数决定子进程和父进程共享的资源种类和数量,这样就有了轻重之分。在内核中, clone()调用经过参数传递和解释后会调用do_fork(),这个核内函数同时也是fork()、vfork()系统调用的最终实现。

在大多数系统中,LWP与普通进程的区别也在于它只有一个最小的执行上下文和调度程序所需的统计信息,而这也是它之所以被称为轻量级的原因。因为LWP之间共享它们的大部分资源,所以它在某些应用程序就不适用了;这个时候就要使用多个普通的进程了。例如,为了避免内存泄漏(a process can be replaced by another one)和实现特权分隔(processes can run under other credentials and have other permissions)。

说完进程再来说说线程,如果说进程就像一个大容器,那么线程就相当于在进程这个容器中又划分出了许多的小隔间。线程也属于进程的一部分。那么这些小隔间将进程这个大容器中的程序划分成了很多独立的部分,但是这些小隔间并没有将整个程序划分干净,还留下了一些,而留下的这些可以在任意的小隔间中游荡。游离于小隔间之外的东西很多时候是个很麻烦的角色,因为会有好多小隔间会同时需要它们,这就必须得制定一些规矩来协调或协商着来。否则就会出乱子,把整个程序弄的四分五裂。协调或商量的方法由操作系统来提供,被称之为线程同步机制。作为一个合格的操作系统,一般都会提供:互斥锁、条件变量、信号量等机制,虽然方法有了,但是如何商量、协调什么这种需要智商的问题,就只能留给程序员们了。

另一个问题,放在一个大容器内的程序不可能什么都能做得来,也需要寻求其他程序的帮助,和它们沟通。同样程序与程序之间的沟通也由操作系统来提供,也就是我们所说的进程通信机制。那么作为一个合格的操作系统,一般都会提供:管道、I/O重定向、套接字等机制。虽然方法有了,沟通什么内容以及沟通的技巧等这些需要智商的事,还得由程序员来处理。

在操作系统层面,对进程和线程的支持时完全不同的,当然目前来看没有谁敢不支持它们。以windows为代表支持线程这一派的操作系统,往往在系统内部是以线程为调度实体的,进程对于这类操作系统来说就是一堆数据结构罢了;以unix为代表的支持进程这一派的操作系统,其系统内部的调度实体是进程,而线程基本上都是在耍花招了。正因为这样,由于windows将进程这个大容器基本上给忽略掉了,而每个线程又那么小巧精悍,调度起来远比unix舞动哪些大容器要轻松许多,从多任务的调度效率上windows占有很大的优势。

所谓有竞争才有动力,看到windows的大红大紫,unix们也不能甘为人下,自然也要在线程方面有所斩获。但是unix本身并不具备对线程的支持,完全照搬windows的设计又心有不甘,于是很多方案被提了出来,对于Linux,恐怕最有名的要数Linux threads方案了。在开始时,Linux是完全的unix克隆,在内核中并不支持线程。但是它的确可以通过clone()系统调用将进程作为调度的实体。这个调用创建了调用进程的一个拷贝,这个拷贝与调用进程共享相同的地址空间。Linux Threads方案使用这个调用来完全在用户控件模拟对线程的支持。不幸的是,这个方案有太多缺点,让windows总是有一种“一直被追赶从未被超越”的自豪。

Linux线程的发展

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

在现在的Linux实现中,线程支持UNIX的可移植操作系统接口(POSIX)标准库。在Linux操作系统中有几种可用的线程实现。以下是广泛使用的线程库:

模型一 :LinuxThreads

linux 2.6以前, pthread线程库对应的实现是一个名叫linuxthreads的lib.这种实现本质上是一种LWP的实现方式,即通过轻量级进程来模拟线程,内核并不知道有线程这个概念,在内核看来,都是进程。

Linux采用的“一对一”的线程模型,即一个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点以外, 都没有实现(实际上是无能为力):

模型二: NPTL

Linux Threads就不分析它了,因为现代的Linux已经不是那个Linux了,因为Linux有了NPTL。NPTL的全称是native posix thread library原生posix线程库,NTPL可以让Linux内核高效地运行那些使用POSIX风格的线程API所编写的程序。NPTL是在Linux2.6内核开始引入的。一个比较有趣的地方是,Linux内核本身的多任务调度实体被称为“内核线程”。而且经常有人会非常兴奋的说,Linux已经跟windows一样了,是以线程为调度实体的。的确不假,从2.6开始,线程是Linux原生支持的特性了,但是与windows还是有很大差别的。首先,windows的调度实体就是线程,进程只是一堆数据结构。而Linux不是,Linux将进程和线程做了同等对待,进程和线程在内核一级没有差别,只是通过特殊的内存映射方法使得它们从用户的角度看来有了进程和线程的差别。其次,windows至今也没有真正的多进程概念,创建进程的开销远大于创建线程的开销。Linux则不然,Linux在内核一级并不区分进程和线程,这使得创建进程的开销与创建进程的开销差不多。

最后,windows与Linux的任务调度策略也不尽相同。window会随着线程越来越多而变得越来越慢,这也是为什么windows服务器在运行一段时间后必须重启的原因。当然,如果你是微软的VIP客户还是有办法避免这个问题的,反观Linux却可以持续运行很长时间,系统效率也没有什么变化。而且Linux也没有VIP用户的说法,因为人人都是VIP。

本质上来说,NPTL还是一个LWP的实现机制,但相对原有LinuxThreads来说,做了很多的改进。下面我们看一下NPTL如何解决原有LinuxThreads实现机制的缺陷。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中, 只能由本线程来处理。当线程停止/继续, 或者是收到一个致命信号时, 内核会将处理动作施加到整个线程组中。

Linux引入了NTPL机制之后,才真正称为了一个可以傲视群雄的操作系统,成为一个真正意义的现代操作系统,当然还有很多特性是Linux所不具备的,这也是Linux之所以没有占领整个天下的根本原因。至于是哪些特性是Linux的软肋,有兴趣可以自己查阅。


如果您觉得本站对你有帮助,那么可以支付宝扫码捐助以帮助本站更好地发展,在此谢过。
喜欢 (0)
[资助本站您就扫码 谢谢]
分享 (0)

您必须 登录 才能发表评论!