linux的进程与线程

本文发布时间: 2019-Mar-22
在许多经典的操作系统教科书中,总是把进程定义为程序的执行实例,它并不执行什么,只是维护应用程序所需的各种资源。而线程则是真正的执行实体,为了让进程完成一定的工作,进程必须至少包含一个线程。进程所维护的是程序所包含的资源(静态资源),如:地址空间,打开的文件句柄集,文件系统状态,信号处理handler等。线程所维护的是运行相关的资源(动态资源),如:运行栈,调度相关的控制信息,待处理的信号集等。然而,一直以来,linux内核并没有线程的概念。每一个执行实体都是一个task_struct结构,通常称之为进程。linux进程是一个执行单元,维护着执行相关的动态资源,同时,它又引用着程序所需的静态资源。通过系统调用clone创建子进程时,可以有选择性地让子进程共享父进程所引用的资源,这样的子进程通常称为轻量级进程。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在linux 2.6以前,pthread线程库对应的实现是一个名叫linuxthreads的lib。linuxthreads利用前面提到的轻量级进程来实现线程,但是对于POSIX提出的那些要求,linuxthreads除了第5点以外,都没有实现(实际上是无能为力):1、如果运行了A程序,A程序创建了10个线程,那么在shell下执行ps命令时将看到11个A进程,而不是1个(注意,也不是10个,下面会解释)2、不管是kill还是pthread_kill, 信号只能被一个对应的线程所接收3、SIGSTOP/SIGCONT信号只对一个线程起作用还好linuxthreads实现了第5点,我认为这一点是最重要的。如果某个线程“挂”了,整个进程还在若无其事地运行着,可能会出现很多的不一致状态,进程将不是一个整体,而线程也不能称为线程了。或许这也是为什么linuxthreads虽然与POSIX的要求差距甚远,却能够存在,并且还被使用了好几年的原因吧。但是,linuxthreads为了实现这个“第5点”,还是付出了很多代价,并且创造了linuxthreads本身的一大性能瓶颈。接下来要说说为什么A程序创建了10个线程,但是ps时却会出现11个A进程了。因为linuxthreads自动创建了一个管理线程,上面提到的“第5点”就是靠管理线程来实现的。当程序开始运行时,并没有管理线程存在(因为尽管程序已经链接了pthread库,但是未必会使用多线程)。程序第一次调用pthread_create时,linuxthreads发现管理线程不存在,于是创建这个管理线程,这个管理线程是进程中的第一个线程(主线程)的儿子。然后在pthread_create中,会通过pipe向管理线程发送一个命令,告诉它创建线程,即是说,除主线程外,所有的线程都是由管理线程来创建的,管理线程是它们的父亲。于是,当任何一个子线程退出时,管理线程将收到SIGUSER1信号(这是在通过clone创建子线程时指定的)。管理线程在对应的sig_handler中会判断子线程是否正常退出,如果不是,则杀死所有线程,然后自杀。那么,主线程怎么办呢?主线程是管理线程的父亲,其退出时并不会给管理线程发信号,于是,在管理线程的主循环中通过getppid检查父进程的ID号,如果ID号是1,说明父亲已经退出,并把自己托管给了init进程(1号进程),这时候,管理线程也会杀掉所有子线程,然后自杀。可见, 线程的创建与销毁都是通过管理线程来完成的,于是管理线程就成了linuxthreads的一个性能瓶颈,创建与销毁需要一次进程间通信,一次上下文切换之后才能被管理线程执行,并且多个请求会被管理线程串行地执行。NPTL到了linux 2.6,glibc中有了一种新的pthread线程库–NPTL(Native POSIX Threading Library)。NPTL实现了前面提到的POSIX的全部5点要求,但是,实际上,与其说是NPTL实现了,不如说是linux内核实现了。在linux 2.6中,内核有了线程组的概念,task_struct结构中增加了一个tgid(thread group id)字段。如果这个task是一个“主线程”(即thread group leader),则它的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是代表一个进程还是代表一个线程(tgid!=pid就是线程了),也就知道在什么时候该展现它们,什么时候不该展现(比如在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中,只能由本线程来处理。当线程停止/继续, 或者是收到一个致命信号时,内核会将处理动作施加到整个线程组中。NGPT说到这里,也顺便提一下NGPT(Next Generation POSIX Threads)。上面提到的两种线程库使用的都是内核级线程(每个线程都对应内核中的一个调度实体),这种模型称为1:1模型(1个线程对应1个内核级线程),而NGPT则打算实现M:N模型(M个线程对应N个内核级线程),也就是说若干个线程可能是在同一个执行实体上实现的。线程库需要在一个内核提供的执行实体上抽象出若干个执行实体,并实现它们之间的调度。这样被抽象出来的执行实体称为用户级线程。大体上,这可以通过为每个用户级线程分配一个栈,然后通过longjmp的方式进行上下文切换。(百度一下“setjmp/longjmp”,你就知道。也可以查阅《C语言接口与实现》,有一篇专门介绍setjmp和longjmp)但是实际上要处理的细节问题非常之多。目前的NGPT好像并没有实现所有预期的功能,并且暂时也不准备去实现。用户级线程的切换显然要比内核级线程的切换快一些,前者可能只是一个简单的长跳转,而后者则需要保存/装载寄存器,进入然后退出内核态。(进程切换则还需要切换地址空间等。)而用户级线程则不能享受多处理器,因为多个用户级线程对应到一个内核级线程上,一个内核级线程在同一时刻只能运行在一个处理器上。不过,M:N的线程模型毕竟提供了这样一种手段,可以让不需要并行执行的线程运行在一个内核级线程对应的若干个用户级线程上,可以节省它们的切换开销。据说一些类UNIX系统(如Solaris)已经实现了比较成熟的M:N线程模型,其性能比起linux的线程还是有着一定的优势的。用更好的思考和更安全的设计开发国防系统 用Ada编写最美的程序进程与线程为什么对于大多数合作性任务,多线程比多个独立的进程更优越呢?这是因为,线程共享相同的内存空间。不同的线程可以存取内存中的同一个变量。所以,程序中的所有线程都可以读或写声明过的全局变量。如果曾用fork() 编写过重要代码,就会认识到这个工具的重要性。为什么呢?虽然fork() 允许创建多个进程,但它还会带来以下通信问题:如何让多个进程相互通信,这里每个进程都有各自独立的内存空间。对这个问题没有一个简单的答案。虽然有许多不同种类的本地IPC (进程间通信),但它们都遇到两个重要障碍:强加了某种形式的额外内核开销,从而降低性能。对于大多数情形,IPC不是对于代码的“自然”扩展。通常极大地增加了程序的复杂性。双重坏事: 开销和复杂性都非好事。如果曾经为了支持 IPC而对程序大动干戈过,那么您就会真正欣赏线程提供的简单共享内存机制。由于所有的线程都驻留在同一内存空间,POSIX线程无需进行开销大而复杂的长距离调用。只要利用简单的同步机制,程序中所有的线程都可以读取和修改已有的数据结构。而无需将数据经由文件描述符转储或挤入紧窄的共享内存空间。仅此一个原因,就足以让您考虑应该采用单进程/多线程模式而非多进程/单线程模式。为什么要用线程?与标准 fork()相比,线程带来的开销很小。内核无需单独复制进程的内存空间或文件描述符等等。这就节省了大量的CPU时间,使得线程创建比新进程创建快上十到一百倍。因为这一点,可以大量使用线程而无需太过于担心带来的CPU 或内存不足。使用 fork() 时导致的大量 CPU占用也不复存在。这表示只要在程序中有意义,通常就可以创建线程。当然,和进程一样,线程将利用多CPU。如果软件是针对多处理器系统设计的,这就真的是一大特性(如果软件是开放源码,则最终可能在不少平台上运行)。特定类型线程程序(尤其是CPU密集型程序)的性能将随系统中处理器的数目几乎线性地提高。如果正在编写CPU非常密集型的程序,则绝对想设法在代码中使用多线程。一旦掌握了线程编码,无需使用繁琐的IPC和其它复杂的通信机制,就能够以全新和创造性的方法解决编码难题。所有这些特性配合在一起使得多线程编程更有趣、快速和灵活。什么是线程?专业点的说法,线程被定义为一个独立的指令流,它本身的运转由操作系统来安排,但是,这意味着什么呢?对软件开发者来说,解释线程最好的描述就是“procedure”可以独立于主程序运行。再进一步,设想一个包含了大量procedure的主程序,然后想象所有这些procedure在操作系统的安排下一起或者独立的运行,这就是对于多线程程序的一个简单描述。问题是,它是如何实现的呢?在弄懂线程之前,第一步要搞清楚Unix进程。进程被操作系统创建,并需要相当多的“开支”,进程包含如下程序资源和程序执行状态信息:进程ID,进程群组ID,用户ID,群组ID环境工作目录程序指令寄存器栈堆文件描述符信号动作共享库进程间通信工具(例如消息队列,管道,信号量,共享内存)Unix进程 Unix进程内部的线程线程使用和在进程内的生存,仍由操作系统来安排并且独立的实体来运行,很大程度上是因为它们为可执行代码的存在复制了刚刚好的基本资源。这个独立的控制流之所以可以实现,是因为线程维护着如下的东西:栈指针寄存器调度属性(例如规则和优先级)等待序列和阻塞信号线程拥有的数据所以,总的来说,Unix环境里的线程有如下特点:它生存在进程中,并使用进程资源;拥有它自己独立的控制流,前提是只要它的父进程还存在,并且OS支持它;它仅仅复制可以使它自己调度的必要的资源;它可能会同其它与之同等独立的线程分享进程资源;如果父进程死掉那么它也会死掉——或者类似的事情;它是轻量级的,因为大部分的开支已经在它的进程创建时完成了。因为在同一进程内的线程分享资源,所以:一个线程对共享的系统资源做出的改变(例如关闭一个文件)会被所有的其它线程看到;指向同一地址的两个指针的数据是相同的;对同一块内存进行读写操作是可行的,但需要程序员作明确的同步处理操作。


(以上内容不代表本站观点。)
---------------------------------
本网站以及域名有仲裁协议。
本網站以及域名有仲裁協議。

2024-Mar-04 02:08pm
栏目列表