Linux线程实现机制分析

. 基础知识:线程和进程

按照教科书上的定义,进程 是资源管理的最小单位,线程是程序执行的最小单位。在操作系统设计上,从进程演化出线程,最主要的目的就是更好的支持SMP 以及减小(进程/ 线程)上下文切换开销。

无论按照怎样的分法,一个 进程至少需要一个线程作为它的指令执行体,进程管理着资源(比如cpu 、内存、文件等等),而将线 程分配到某个cpu 上执行。一个进程当然可以拥有多个线程,此时,如果进程运行在SMP 机器上,它就可以同时使用多个cpu 来执行各个线 程,达到最大程度的并行,以提高效率;同时,即使是在单cpu 的机器上,采用多线程模型来设计程 序,正如当年采用多进程模型代替单进程模型一样,使设计更简洁、功能更完备,程序的执行效率也更高,例如采用多个线程响应多个输入,而此时多线程模型所实 现的功能实际上也可以用多进程模型来实现,而与后者相比,线程的上下文切换开销就比进程要小多了,从语义上来说,同时响应多个输入这样的功能,实际上就是 共享了除cpu 以外的所有资源的。

针对线程模型的两大意义, 分别开发出了核心级线程和用户级线程两种线程模型,分类的标准主要是线程的调度者在核内还是在核外。前者更利于并发使用多处理器的资源,而后者则更多考虑 的是上下文切换开销。在目前的商用系统中,通常都将两者结合起来使用,既提供核心线程以满足smp 系 统的需要,也支持用线程库的方式在用户态实现另一套线程机制,此时一个核心线程同时成为多个用户态线程的调度者。正如很多技术一样," 混合" 通常都能带来更高的效率,但同时也带来更大的实 现难度,出于" 简单" 的设计思路,Linux 从一开始就没有实现混合模型的计划,但它在实现上采用了另一种思路的" 混合"

在线程机制的具体实现上, 可以在操作系统内核上实现线程,也可以在核外实现,后者显然要求核内至少实现了进程,而前者则一般要求在核内同时也支持进程。核心级线程模型显然要求前者 的支持,而用户级线程模型则不一定基于后者实现。这种差异,正如前所述,是两种分类方式的标准不同带来的。

当核内既支持进程也支持线 程时,就可以实现线程- 进程的" 多对多" 模型,即一个进程的某个线程由核内调度,而同时它也可以作为用户级线程池的调度者,选择合适的用户级线程在 其空间中运行。这就是前面提到的" 混合" 线 程模型,既可满足多处理机系统的需要,也可以最大限度的减小调度开销。绝大多数商业操作系统(如Digital UnixSolarisIrix ) 都采用的这种能够完全实现POSIX1003.1c 标准的线程模型。在核外实现的线程又可以分为" 一对一"" 多 对一" 两种模型,前者用一个核心进程(也许是轻量进程)对应一个线程,将线程调度等同于进程调度, 交给核心完成,而后者则完全在核外实现多线程,调度也在用户态完成。后者就是前面提到的单纯的用户级线程模型的实现方式,显然,这种核外的线程调度器实际 上只需要完成线程运行栈的切换,调度开销非常小,但同时因为核心信号(无论是同步的还是异步的)都是以进程为单位的,因而无法定位到线程,所以这种实现方 式不能用于多处理器系统,而这个需求正变得越来越大,因此,在现实中,纯用户级线程的实现,除算法研究目的以外,几乎已经消失了。

Linux 内核只提供了轻量进程的支持,限制了更高效的线程模型的实现,但Linux 着重优化了进程的调度开销,一定程度上也弥补了这一缺陷。目前最流行的线程机制LinuxThreads 所采用的就是线程- 进程" 一对一" 模型,调度交给核心,而在用户级实现一个包 括信号处理在内的线程管理机制。Linux-LinuxThreads 的运行机制正是本文的描述重 点。

.Linux 2.4 内核中的轻量进程实现

最初的进程定义都包含程 序、资源及其执行三部分,其中程序通常指代码,资源在操作系统层面上通常包括内存资源、IO 资源、 信号处理等部分,而程序的执行通常理解为执行上下文,包括对cpu 的占用,后来发展为线程。在线程 概念出现以前,为了减小进程切换的开销,操作系统设计者逐渐修正进程的概念,逐渐允许将进程所占有的资源从其主体剥离出来,允许某些进程共享一部分资源, 例如文件、信号,数据内存,甚至代码,这就发展出轻量进程的概念。Linux 内核在2.0.x 版本就已经实现了轻量进程,应用程序可以通过一个统一的clone() 系 统调用接口,用不同的参数指定创建轻量进程还是普通进程。在内核中,clone() 调用经过参数传 递和解释后会调用do_fork() ,这个核内函数同时也是fork()vfork() 系统调用的最终实现:

 

<linux-2.4.20/kernel/fork.c> int do_fork(unsigned long clone_flags, unsigned long stack_start, struct pt_regs *regs, unsigned long stack_size)

 

其中的clone_flags 取自以下宏的"" 值:

 

<linux-2.4.20/include/linux/sched.h> #define CSIGNAL 0x000000ff /* signal mask to be sent at exit */ #define CLONE_VM 0x00000100 /* set if VM shared between processes */ #define CLONE_FS 0x00000200 /* set if fs info shared between processes */ #define CLONE_FILES 0x00000400 /* set if open files shared between processes */ #define CLONE_SIGHAND 0x00000800 /* set if signal handlers and blocked signals shared */ #define CLONE_PID 0x00001000 /* set if pid shared */ #define CLONE_PTRACE 0x00002000 /* set if we want to let tracing continue on the child too */ #define CLONE_VFORK 0x00004000 /* set if the parent wants the child to wake it up on mm_release */ #define CLONE_PARENT 0x00008000 /* set if we want to have the same parent as the cloner */ #define CLONE_THREAD 0x00010000 /* Same thread group? */ #define CLONE_NEWNS 0x00020000 /* New namespace group? */ #define CLONE_SIGNAL (CLONE_SIGHAND | CLONE_THREAD)

 

do_fork() 中,不同的clone_flags 将 导致不同的行为,对于LinuxThreads ,它使用(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND )参数来调用clone() 创 建" 线程" ,表示共享内存、共享文件系统访 问计数、共享文件描述符表,以及共享信号处理方式。本节就针对这几个参数,看看Linux 内核是如 何实现这些资源的共享的。

1.CLONE_VM

do_fork() 需要调用copy_mm() 来 设置task_struct 中的mmactive_mm 项,这两个mm_struct 数据 与进程所关联的内存空间相对应。如果do_fork() 时指定了CLONE_VM 开关,copy_mm() 将把新的task_struct 中的mmactive_mm 设置成与current 的相同,同时 提高该mm_struct 的使用者数目(mm_struct::mm_users )。 也就是说,轻量级进程与父进程共享内存地址空间,由下图示意可以看出mm_struct 在进程中的 地位:


2.CLONE_FS

task_struct 中利用fsstruct fs_struct * )记录了进程所在文件系统的根目录和当前目录信息,do_fork() 时调用copy_fs() 复制了这个 结构;而对于轻量级进程则仅增加fs->count 计数,与父进程共享相同的fs_struct 。也就是说,轻量级进程没有独立的文件系统相关的信息,进程中任何一个线程改变当前目录、根 目录等信息都将直接影响到其他线程。

3.CLONE_FILES

一个进程可能打开了一些文 件,在进程结构task_struct 中利用filesstruct files_struct * )来保存进程打开的文件结构(struct file )信息,do_fork() 中调 用了copy_files() 来处理这个进程属性;轻量级进程与父进程是共享该结构的,copy_files() 时仅增加files->count 计 数。这一共享使得任何线程都能访问进程所维护的打开文件,对它们的操作会直接反映到进程中的其他线程。

4.CLONE_SIGHAND

每一个Linux 进程都可以自行定义对信号的处理方式,在task_struct 中 的sigstruct 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 (Xavier.Leroy@inria.fr) 负 责开发完成,并已绑定在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 仅使用了其中的errnop_pidp_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_MAX128LinuxThreads 使用PTHREAD_KEYS_MAX1024 ;私有数据释放时允许执行的操作数,LinuxThreadsPOSIX 一致,定义PTHREAD_DESTRUCTOR_ITERATIONS4 ;每进程的线程数,POSIX 定义为64LinuxThreads 增大到1024PTHREAD_THREADS_MAX ); 线程运行栈最小空间大小,POSIX 未指定,LinuxThreads 使 用PTHREAD_STACK_MIN16384 (字 节)。

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 中记录了轻量级进程号以及核外分配和管理的线程id2*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 的线程idPTHREAD_THREADS_MAX__pthread_manager_thread 的 是2*PTHREAD_THREADS_MAX+1 ,第一个用户线程的线程idPTHREAD_THREADS_MAX+2 ,此 后第n 个用户线程的线程id 遵循以下公式:

 

tid=n*PTHREAD_THREADS_MAX+n+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 的处理是这样 的:

 

if (clone_flags & CLONE_PID) { if (current->pid) goto fork_out; }

 

这段代码表明,目前的Linux 内核仅在pid0 的时候认可CLONE_PID 参数,实际上,仅在SMP 初始化,手工创建进程的时候才会使用CLONE_PID 参 数。

按照POSIX 定义,同一进程的所有线程应该共享一个进程id 和 父进程id ,这在目前的" 一对一" 模型下是无法实现的。

2) 信号处理问题

由于异步信号是内核以进程 为单位分发的,而LinuxThreads 的每个线程对内核来说都是一个进程,且没有实现" 线程组" ,因此,某些语义不符合POSIX 标准,比如没有实现向进程中所有线程发送信号,README 对 此作了说明。

如果核心不提供实时信号,LinuxThreads 将使用SIGUSR1SIGUSR2 作为内部使用的restartcancel 信号,这样应用程序就不能使用这两个原本为用户保留的信号了。在Linux kernel 2.1.60 以后的版本都支持扩展的实时信号(从_SIGRTMIN_SIGRTMAX ),因此不存在 这个问题。

某些信号的缺省动作难以在 现行体系上实现,比如SIGSTOPSIGCONTLinuxThreads 只能将一个线程挂起,而无法挂起整个进程。

3) 线程总数问题

LinuxThreads 将每个进程的线程最大数目定义为1024 ,但实际上这个数值还受到整个系统的总进程数限制,这又是由于线程其实是核心进程。

kernel 2.4.x 中,采用一套全新的总进程数计算方法,使得总进程数基本上仅受限于物理内存的大小,计 算公式在kernel/fork.cfork_init() 函 数中:

 

max_threads = mempages / (THREAD_SIZE/PAGE_SIZE) / 8

 

i386 上,THREAD_SIZE=2*PAGE_SIZEPAGE_SIZE=2^124KB ),mempages= 物理内存大小/PAGE_SIZE , 对于256M 的 内存的机器,mempages=256*2^20/2^12=256*2^8 ,此时最大线程数为4096

但为了保证每个用户(除了root )的进程总数不至于占用一半以上物理内存,fork_init() 中 继续指定:

 

init_task.rlim[RLIMIT_NPROC].rlim_cur = max_threads/2; init_task.rlim[RLIMIT_NPROC].rlim_max = max_threads/2;

 

这些进程数目的检查都在do_fork() 中进行,因此,对于LinuxThreads 来 说,线程总数同时受这三个因素的限制。

4) 管理线程问题

管理线程容易成为瓶颈,这 是这种结构的通病;同时,管理线程又负责用户线程的清理工作,因此,尽管管理线程已经屏蔽了大部分的信号,但一旦管理线程死亡,用户线程就不得不手工清理 了,而且用户线程并不知道管理线程的状态,之后的线程创建等请求将无人处理。

5) 同步问题

LinuxThreads 中的线程同步很大程度上是建立在信号基础上的,这种通过内核复杂 的信号处理机制的同步方式,效率一直是个问题。

6 )其他POSIX 兼容性问题

Linux 中很多系统调用,按照语义都是与进程相关的,比如nicesetuidsetrlimit 等,在目前的LinuxThreads 中, 这些调用都仅仅影响调用者线程。

7 )实时性问题

线程的引入有一定的实时性考虑,但LinuxThreads 暂 时不支持,比如调度选项,目前还没有实现。不仅LinuxThreads 如此,标准的Linux 在实时性上考虑都很少。

. 其他的线程实现机制

LinuxThreads 的问题,特别是兼容性上的问题,严重阻碍了Linux 上的跨平台应用(如Apache )采用多线程 设计,从而使得Linux 上的线程应用一直保持在比较低的水平。在Linux 社区中,已经有很多人在为改进线程性能而努力,其中既包括用户级线程库,也包括核心级和用户级配合改 进的线程库。目前最为人看好的有两个项目,一个是RedHat 公司牵头研发的NPTLNative Posix Thread Library ), 另一个则是IBM 投资开发的NGPTNext 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 的开放源码项目NGPT2003110 日推出了稳定的2.2.0 版,但相关的文档工作还差很多。就目前所知,NGPT 是基于GNU PthGNU Portable Threads )项目而实现的M:N 模 型,而GNU Pth 是一个经典的用户级线程库实现。

按照20033NGPT 官 方网站上的通知,NGPT 考虑到NPTL 日 益广泛地为人所接受,为避免不同的线程库版本引起的混乱,今后将不再进行进一步开发,而今进行支持性的维护工作。也就是说,NGPT 已经放弃与NPTL 竞争下一代Linux POSIX 线程库标准。

3. 其他高效线程机制

此处不能不提到Scheduler Activations 。 这个1991 年在ACM 上发表的多线程内核 结构影响了很多多线程内核的设计,其中包括Mach3.0NetBSD 和 商业版本Digital Unix (现在叫Compaq True64 Unix )。它的实质是在使用用户级线程调度的同时,尽可能地减少用户级对核心的系统调用请求,而后者往往是运行开销的重要 来源。采用这种结构的线程机制,实际上是结合了用户级线程的灵活高效和核心级线程的实用性,因此,包括LinuxFreeBSD 在内的多个开放源码操作系统设计社区都在进行相关研究,力图在本系统中实现Scheduler Activations

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值