关于LinuxThreads线程库的效率讨论

来源:互联网 发布:仙界网络直播间笔趣 编辑:程序博客网 时间:2024/05/10 22:21

自从通过操作系统教科书接受到有关线程的知识以来,就觉得线程是一种很高效的方式,线程的创建、切换、回收都只涉及很少的资源转换。
在做windows程序开发的时候,上述这一认识得到了很好的印证,因为windows实现的进程/线程与教科书上所述无异。
开始从事linux程序开发以后,逐渐发现linux内核原来并不支持线程。对于我们现在使用的linux线程库(linuxthreads)来说,线程的本质就是进程(linux内核把每个线程都当作进程看待);而仅仅是在程序员的使用方式上,它表现为线程。但要实现这一点,线程库从中必定要做一些手脚。或许线程库要做一些有碍效率的事情,以至于使用线程可能会比使用进程更没效率,而仅仅是比之方便而已。
带着关于效率的疑问,对线程库做了一些研究。
下面先引用一篇文章,分析一下linux线程库(linux threads)的实现:

http://www.ibm.com/developerworks/cn/linux/kernel/l-thread/index.html

 

大致了解了linux threads这个线程库的实现之后,我们来分析一下基于这个库的线程效率如何。主要需要讨论的是两方面,创建和回收线程、线程切换。

首先得介绍一下linux进程的内存区,因为不管是在线程创建,还是切换,关于内存区的处理都是主要的开销之一。
在linux内核中,进程结构体task_struct里面有一个mm指针,指向一个mm_struct结构体对象,这个就是该进程的内存区。mm_struct中有一个vm_area_struct结构体对象的集合(以AVL树方式组织),每一个vm_area_struct对象代表一个存储区间(可参阅引用文档中的图1)。这些区间分别表示了进程的代码段(注意,linux并没有使用段式存储,这里只是习惯性的称呼而已)、数据段、栈、堆、等等(每一种类可能包含若干个区间)。

按上面文档中所述的方式调用clone之后,新老进程的mm_struct结构是完全共用的,包括栈。所以,必须得从堆中malloc一个新的空间作为新进程的栈,否则两个进程共用一个栈,势必导致错误。(另外一种共用mm_struct结构的clone的应用是:父进程clone之后就sleep,等待子进程执行完毕后,再将父进程wakeup。父子进程不同时使用栈。)(而如果是fork,整个mm_struct结构将被复制,包括下面要讲到的pgd所指向的整个页表。)

mm_struct结构中还有一个pgd指针,它指向一个属于该进程的页表。关于页表,这里再简要介绍一下。页表是页式存储管理中,用于将逻辑地址(即程序代码中使用的地址,每个程序都可以使用相同的地址,不必担心它会冲突)转换成物理地址的(实际上这只是内存单元在系统总线上的地址,还不一定就是真正的内存物理地址)的一张转换表。页表分为两级(linux实现的页表为两级或三级,这里仅以两级为例),mm中的pgd指针就指向第一级页表。逻辑地址的高10位作为一级页表的偏移量,在一级页表中取出一个页表项,该页表项指向了一个二级页表;再以逻辑地址的第二个10位为偏移量,从二级页表中取出页表项,该页表项的值与逻辑地址低12位之和就是真正的物理地址。这一过程就是地址映射。

 

既然一个逻辑地址需要经过pgd指向的页表的转换才能变成物理地址,那么pgd及其指向的页表的物理地址又是如何得来的呢?实际上,pgd及它指向的页表、以及mm_struct、task_struct等等结构都是存在于内核空间中的。而内核空间不存在地址映射,里面使用的地址等同于物理地址。因为内核代码在整个系统中就只有一份,不必担心存储空间冲突,只要约定内核使用的地址空间与用户态进程使用的空间不重合就可以了。

想一想,每一次用户空间的内存访问都需要进行内存映射,而映射过程中又伴随着多次内存访问,这是不是太没效率了(进行了这么多次内存访问,只有一次是真正需要的)?所以,CPU内部一般都要提供专门用于内存映射的高速缓存,pgd指向的页表会在使用中逐渐被装入这个高速缓存,这就是所谓的快表。快表中的存取速度大概比内存高一个数量级,所以,有了快表的帮助,内存映射的开销就显得并不是那么大了。

总的说来,内核是地址映射的维护者。进程所对应的页表是保存在内核的地址空间中的,由内核来维护逻辑地址与物理地址的映射关系。维护这一关系的依据有两方面:一方面是前面提到的vm_area_struct,它用来管理进程的逻辑地址空间(进程使用到了多少个区间,每个区间的逻辑地址分别是从哪里到哪里?);另一方面是内核所维护的一个总的物理页面集合(逻辑地址是按进程来管理的,而物理地址在系统内则是全局性质的),物理内存被按页(4096字节)划分,并管理起来。当内核为一个vm_area_struct区间里的某一块内存(一个页面大小)选定一个对应的物理页面时,对应的映射关系就在进程的pgd所指向的页表中生成。
而CPU则是地址映射的执行者。(关于地址映射的这一系列操作是由CPU内部的MMU(内存管理单元)来完成的。有的CPU不带MMU,那内存映射就只能由它自己完成了。)在X86体系结构的CPU中,通过CR3寄存器来保存页表指针(也就是将pgd指针装入CR3寄存器)。linux在进程切换时,就是通过修改CR3寄存器的值,来让CPU使用不同进程对应的页表,从而达到内存区切换的目的。这个过程看上去仅仅是修改了CR3寄存器,但是由于CR3寄存器的被修改,CPU中维护的用于内存映射的快表就得废弃了。于是就需要通过接下来的大量内存访问,再逐步把快表建立起来(并不是一次性把页表全读入,而是在需要用到的时候,才把需要用到的部分读入)。可想,如果频繁地进行进程切换,内存访问的效率是很低的(大量内存访问都要浪费在页表的读取上)。而内存访问又是进程运行中最频繁发生的事情(最直接的,CPU执行的指令,就是从内存取得的)。所以,在切换问题上,使用线程就要比使用进程有优势,因为属于同一进程的线程都是共用一个mm_struct,也即是具有相同的pgd,在这些线程间发生切换时,是不需要修改页表的。

关于线程的创建和回收,在上面文章的第二部分讲到,线程创建时使用clone系统调用,避免了fork调用中的内存区拷贝、文件系统目录信息拷贝、打开文件拷贝、信号处理handle拷贝,效率比fork要高很多。相应的线程回收时的释放动作也要简捷得多。

而其中,在内存区的拷贝方面,创建线程比起创建进程的优势也并不是那么大。虽然说fork是把父进程原样拷贝(所使用的内存都拷贝了),但是实际上linux使用了“写时复制”的技术,进程使用的内存空间所对应的物理页面在fork完成时,是没有被复制的(复制的只是用于管理逻辑地址空间的那一部分内容,如vm_area_struct),而是父子进程共享同一组物理页面。此时,内核会将父子进程的所有内存区都设为只读。
之后,当父子进程之一对某一个区间进行写操作时,就会发生一次异常(可以想象,只要父子进程之一一开始运行,可能立即就会出现这样的异常,因为进程的栈空间随时可能性被写。而进程的代码区间则可能永远也不会遇到这样的异常)。内核自己知道这个异常是由于具体的内存空间尚未复制引起的,于是才真正把内存空间复制了。这样看来,fork的效率也不是很糟糕。

可见,由于线程使用了轻量级进程来实现,在创建、销毁、和切换方面比起普通进程还是有一定的优势。
然而线程的不足之处在上面引用的文档中也提到了,就是管理线程。
那么,如果我们绕开线程库,不使用线程库封装过的轻量级进程,而直接使用原有的轻量级进程,启不是效率更高?的确是这样。但是,线程库之所以存在,就是为了兼容POSIX定义的线程标准,使得linux下编写的程序能够轻松地移植到其他的类UNIX操作系统中。如果没有打算移植,那么直接使用轻量级进程无疑是更完美的。

原创粉丝点击