进程与线程

前言

操作系统最核心的概念就是进程,这是对正在运行程序的一个抽象。操作系统的其他所有内容都是围绕着进程展开的。本文旨在根据操作系统的进程与线程概念,来剖析Linux系统对于进程和线程的实现源码。

进程

1 进程模型

1.1 进程定义

在进程模型中,所有在计算机上运行的软件,通常也包括操作系统,被组织成若干顺序进程,简称进程。一个进程就是一个正在执行的程序的实例,进程也包括程序计数器、寄存器和变量的当前值。从概念上来说,每个进程都有各自的虚拟 CPU ,但是实际情况是 CPU 会在各个进程之间来回切换。也就是说,对于一个单核 CPU ,同一时刻只能运行一个进程

从内核角度看,进程由用户内存空间和一系列内核数据结构组成,其中用户内存空间包含了程序代码及代码所使用的变量,而内核数据结构则用于维护进程状态信息。记录在内核数据结构中的信息包括许多与进程相关的标识号、虚拟内存表、打开文件的描述符表、信号传递及处理的有关信息、进程资源使用及限制、当前工作目录和大量的其他信息。

1.2 并发和并行

并发是问题域中的概念-程序需要被设计成能够处理多个同时(或者几乎同时)发生的事件;而并行则是方法域中的概念-通过将问题中的多个部分并行执行,来加速解决问题。简单的来说,就是,并发是交替做不同事情的能力,而并行是同时做不同事情的能力,如下图所示:

1.3 进程内存布局

对于进程而言,Linux操作系统采用的是虚拟内存管理技术,这使得进程都拥有了独立的虚拟内存空间。该内存空间的大小为 4G 的线性虚拟空间,进程只需关注自己可以访问的虚拟地址,无需直到物理地址的映射情况。利用这种虚拟地址不但更安全(用户不能直接访问物理内存),而且用户程序可以使用比实际物理内存更大的地址空间。

4GB 的进程地址空间会被分成两个部分——用户空间与内核空间。用户地址空间是 0~3GB(0xC0000000),内核地址空间占据 3~4GB 。用户进程在通常情况下只能访问用户空间的虚拟地址,不能访问内核空间虚拟地址。只有用户进程使用系统调用(代表用户进程在内核态执行)时才可以访问到内核空间。每当进程切换,用户空间就会跟着产生变化;而内核空间是由内核负责映射,它并不会跟着进程改变,是固定的。内核空间地址有自己对应的页表,用户进程各自有不同的页表。每个进程的用户空间都是完全独立、互不相干的。

每个进程所分配的内存由很多部分组成,通常称之为段(segment)。如下所示:

  • 文本段:该部分包含了进程运行的程序机器语言指令,且具有只读属性。因为多个进程可以同时运行同一程序,所以又将文本段设为可共享。
  • 初始化数据段:该部分包含显式初始化的全局变量和静态变量。当程序加载到内存时,从可执行文件中读取这些变量的值。
  • 未初始化数据段:该部分也成为BSS段,其中包含了未进行显式初始化的全局变量和静态变量。程序启动之前,系统将本段内所有内存初始化为 0。
  • :该部分是一个动态增长和收缩的段,由栈帧(stack frames)组成。
  • :该部分是可在运行时(为变量)动态进行内存分配的一块区域。堆顶端称作program break

如下图所示展示了各种内存段在x86-32体系结构中的布局,该图的顶部标记为argvenviron的空间用来存储程序命令行实参和进程环境列表。图中标灰的区域表示这些范围在进程虚拟地址空间中不可用,也就是说,没有为这些区域创建页表。

1.4 虚拟内存管理

虚拟内存的规划之一是将每个程序使用的内存切割成小型的、固定大小的页(page)单元。相应地,将 RAM 划分成一系列与虚存页尺寸相同的页帧。任一时刻,每个程序仅有部分页需要驻留在物理内存页帧中。这些页构成了所谓驻留集。程序未使用的页拷贝保存在交换区内——这是磁盘空间中的保留区域,作为计算机 RAM 的补充——仅在需要时才会载入物理内存。若进程欲访问的页面目前并未驻留在物理内存中,将会发生页面错误,内核即刻挂起进程的执行,同时从磁盘中将该页面载入内存。

在x86-32中,页面大小为4096个字节。

为支持这一组织方式,内核需要为每个进程维护一张页表。该页表描述了每页在进程虚拟地址空间中的位置(可为进程所用的所有虚拟内存页面的集合)。页表中的每个条目要么指出一个虚拟页面在 RAM 中的所在位置,要么表明其当前驻留在磁盘上。在进程虚拟地址空间中,并非所有的地址范围都需要页表条目。通常情况下,由于可能存在大段的虚拟地址空间并未投入使用,故而也无必要为其维护相应的页表条目。若进程试图访问的地址并无页表条目与之对应,那么进程将收到一个SIGSEGV信号。

1.4.1 MMU工作原理

下面我们来看看 MMU(内存管理单元)的工作原理,它是如何将虚拟地址转化为物理地址的。

虚拟地址 8196(二进制 0010 0000 0000 0100)用上面的页表映射图所示的 MMU 映射机制进行映射,输入的 16 位虚拟地址被分为 4 位的页号和 12 位的偏移量。4 位的页号可以表示 16 个页面,12 位的偏移可以为一页内的全部 4096 个字节。

可用页号作为页表的索引,以得出对应于该虚拟页面的页框号。如果在/不在位是 0 ,则引起一个操作系统陷入。如果该位是 1 ,则将在页表中查到的页框号复制到输出寄存器的高 3 位中,再加上输入虚拟地址中的低 12 位偏移量。如此就构成了 15 位的物理地址。输出寄存器的内容随即被作为物理地址送到总线。

1.4.2 虚拟地址空间的意义

虚拟内存管理使进程的虚拟地址空间与 RAM 物理地址空间隔离开来,这带来许多优点:

  • 进程与进程、进程与内核相互隔离,所以一个进程不能读取或修改另一进程或内核的内存。
  • 适当情况下,两个或者更多进程能够共享内存。内存共享常发生于如下两种场景:
    • 执行同一程序的多个进程,可共享一份(只读的)程序代码副本。
    • 进程可以使用shmget()mmap()系统调用显式地请求与其他进程共享内存区。
  • 便于实现内存保护机制;也就是说,可以对页表条目进行标记,以表示相关页面内容是可读、可写、可执行亦或是这些保护措施的组合。
  • 程序员和编译器、链接器之类的工具无需关注程序在 RAM 中的物理布局。
  • 因为需要驻留在内存中的仅是程序的一部分,所以程序的加载和运行都很快。
  • 提高了 CPU 的利用率

更多内存管理及虚拟地址空间知识请去阅读《现代操作系统》及《Linux内核设计与实现》

1.5 进程的运行状态

在一个进程的活动期间至少具备三种基本状态,即运行状态就绪状态阻塞状态

  • 运行态:该时刻进程占用CPU
  • 就绪态:可运行,但因为其他进程正在运行而暂停停止
  • 阻塞态:该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它 CPU 控制权,它也无法运行

其中,转换 2 和转换 3 都是由进程调度程序(操作系统的一部分)引起的,进程本身不知道调度程序的存在。转换 2 的出现说明进程调度器认定当前进程已经运行了足够长的时间,是时候让其他进程运行 CPU 时间片了。当所有其他进程运行过后,这时候该是让第一个进程重新获得 CPU 时间片的时候了,就会发生转换 3。

对于上图所示模型,我们可以看出,基于进程的操作系统中最底层的是中断和调度处理,在该层之上是顺序进程。

操作系统最底层的就是调度程序,在它上面有许多进程。所有关于中断处理、启动进程和停止进程的具体细节都隐藏在调度程序中。事实上,调度程序只是一段非常小的程序。

1.6 进程表

操作系统为了执行进程间的切换,会维护一张表格,这张表就是进程表。每个进程占用着一个进程表项,也就是我们常说的进程控制块(PCB)。该表项包含了进程的重要状态信息,包括程序计数器、堆栈指针、内存分配状态、所打开的文件状态、账号和调度信息,以及其他在进程运行由运行态转化到就绪态或阻塞态时所必须保存的信息,从而保证该进程随后能再次启动,就像从未中断过一样。如下图所示,展示了典型系统的关键字段:

其中第一列内容与进程管理有关,第二列内容与存储管理有关,第三列内容与文件管理有关。

1.7 进程控制块(PCB)

在操作系统中,是用进程控制块(process control block,PCB)数据结构来描述进程的,也称作进程描述符。PCB 是进程存在的唯一标识,这意味着一个进程的存在,必然会有一个 PCB,如果进程消失了,那么 PCB 也会随之消失。

PCB中所包含的信息:

  • 进程描述信息
    • 进程描述符:标识各个进程,每个进程都有一个并且唯一的标识符
    • 用户标识符:进程归属的用户,用户标识符主要为共享和保护服务
  • 进程控制和管理信息
    • 进程当前状态:如 new、ready、running、waiting 或 blocked 等
    • 进程优先级:进程抢占 CPU 时的优先级
  • 资源分配清单
    • 有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用的 I/O 设备信息
  • CPU相关信息
    • CPU 中各个寄存器的值,当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中,以便进程重新执行时,能从断点处继续执行

PCB的数据结构:

通常是通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列。比如:

  • 将所有处于就绪状态的进程链在一起,称为就绪队列
  • 把所有因等待某事件而处于等待状态的进程链在一起就组成各种阻塞队列
  • 另外,对于运行队列在单核 CPU 系统中则只有一个运行指针了,因为单核 CPU 在某个时间,只能运行一个程序

1.8 进程的层次结构

在一些系统中,当一个进程创建了其他进程之后,父进程和子进程就会以某种方式进行关联。子进程自己又会创建更多的子进程,从而形成一个进程的层次结构。

在Unix系统中,进程和它所有的子进程以及子进程的子进程共同组成一个进程组。其次,整个操作系统中的所有进程都是隶属于一个单以init为根的进程树。

1.9 僵死进程、孤儿进程及守护进程

  • 僵死进程:指的是一个已经终止、但是其父进程尚未对其进行善后处理(获取终止子进程的相关信息、释放它仍占有的资源)的进程
  • 孤儿进程:指的是在其父进程执行完成或被终止后仍继续运行的一类进程。这些孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作
  • 守护进程:是创建守护进程时有意把父进程结束,然后被 1 号进程init收养

2 进程操作

2.1 获取PID

函数原型

#include <unistd.h>
pid_t getpid();		// 获取进程的进程ID
pid_t getppid();	// 获取父进程的进程ID
uid_t getuid();		// 获取进程的实际用户ID
uid_t geteuid();	// 获取进程的有效用户ID
gid_t getgid();		// 获取进程的实际组ID
gid_t getegid();	// 获取进程的有效组ID

2.2 进程的创建

函数原型

#include <unistd.h>
pid_t fork();
pid_t vfork();

返回值

子进程返回0,父进程返回子进程ID;若出错,返回 -1,并设置相应的 errno 值。

描述

同样是创建子进程,其效率比fork()要快。两者区别有:

  • vfork()不会创建并复制父进程的地址空间,而是和父进程共享
  • vfork()会阻塞父进程,只运行子进程运行
  • 当子进程调用exec()或_exit()时,内核返回地址空间给父进程并唤醒它

2.3 进程的终止

进程有5种正常终止及3种异常终止方式:

  • 正常终止

    • 在 main 函数内执行 return 语句,这等效于调用 exit
    • 调用 exit 函数,其操作包括调用各终止处理程序,然后关闭所有标准 I/O 流等
    • 调用 _exit 或 _Exit 函数。在Unix系统中,这两者同义,并不冲洗标准 I/O 流
    • 进程的最后一个线程在启动例程中执行return语句。但是该线程的返回值不用作进程的返回值。当最后一个线程从其启动例程返回时,该进程以终止状态 0 返回
    • 进程的最后一个线程调用 pthread_exit 函数
  • 异常终止

    • 调用 abort 。它产生SIGABRT信号
    • 当进程接收到某些信号时
    • 最后一个线程对“取消 cancellation ”请求作出响应

2.4 回收进程资源

函数原型

#include <sys/wait.h>
pid_t wait(int *statloc);
pid_t waipid(pid_t pid, int* statloc, int options);
int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);

返回值

waitwaitpid:若成功,返回进程ID;若出错,返回0或-1
waitid:若成功,返回0;若出错,返回-1

具体相关接口请去阅读《Unix环境高级编程》或者《Linux/Unix系统编程手册》

3 源码剖析

3.1 进程描述符及任务结构

Linux内核把进程的列表存放在叫做任务队列双向循环链表中。链表中的每一项都是类型为task_struct、称作进程描述符的结构,该结构定义在<linux/sched.h>文件中。进程描述符中包含了一个具体进程的所有信息。在 32 位机器上,进程描述符大约有 1.7 KB。

Linux通过slab分配器分配 task_struct 结构,这样能达到对象复用缓存着色的目的(通过预先分配和重复使用 task_struct,可以避免动态分配和释放所带来的资源消耗)。在 2.6 以前的内核中,各个进程的 task_struct 存放在它们内核栈的尾端,而现在由 slab 分配器动态生成 task_struct 只需在栈底(对于向下增长的栈来收)或栈顶(对于向上增长的栈来说)创建一个新的结构struct thread_info。在 x86 上,struct thread_info 在文件<asm/thread_info.h>中定义如下:

struct thread_info {
  struct task_struct   *task;
  struct exec_domain   *exec_domain;
  __u32                flags;
  __u32                status;
  __u32                cpu;
  int                  preempt_count;
  mm_segment_t         addr_limit;
  struct restart_block restart_block;
  void                 *sysenter_return;
  int                  uaccess_err;
};
如图所示,显示了进程描述符和进程内核堆栈的存放方式。进程描述符起始于这个内存区的开始,而栈起始于末端。

因为一个页大小是 4K,一个页的起始地址都是 4K 的整数倍,即后 12 位都为 0,取得esp内核栈栈顶的地址,将其后 12 位取 0,就可以得到上述内存区域的起始地址 0x015fa000,该地址即是thread_info 的地址,通过 thread_info 又可以得到 task_struct 的地址进而得到进程 pid。这项工作由current宏来完成。

esp 寄存器是 CPU 栈指针,用来存放栈顶地址。在 Intel 系统中,栈起始于末端,并朝着这个内存区开始的方向增长。从用户态切换到内核态以后,进程的内核态堆栈总是空的,因此,esp寄存器直接指向这个内存区的顶端,即高地址处,同时也是栈底位置。一旦数据写入堆栈,esp的值就递减,即栈往低地址方向进行增长。C语言使用如下的联合体表示这种混合结构:

union task_union {
  struct task_struct task;
  unsigned long stack[2048];
};

为何要将内核栈和 thread_info 紧密的放在一起?其次为什么要选用联合体,而不是结构体?

最主要的原因就是内核可以很容易的通过 esp 寄存器的值获得当前正在运行进程的 thread_info 结构的地址,进而获得当前进程描述符的地址。其次,由于 thread_info 很小,而且 x86 的堆栈是从上往下,正常情况下内核栈增长是不会覆盖 thread_info 的数据的。如果覆盖了 thread_info 就会导致 overflow,即栈溢出,所以采用 union 的结构将更省内存。

3.2 进程状态

进程描述符中的状态域描述了进程的当前状态。系统中每个进程都必然处于五种进程状态中的一种。该域的值也必为下列五种状态标志之一:

  • TASK_RUNNING:运行。进程是可执行的或者正在执行,或者在运行队列中等待执行。
  • TASK_INTERRUPTIBLE:可中断。进程正在睡眠,即就是被阻塞,等待某些条件的达成。
  • TASK_UNINTERRUPTIBLE:不可中断。除了就算是接收到信号或准备投入运行外,这个状态与可中断状态相同。
  • __TASK_TARCED:被其他进程跟踪的进程
  • __TASK_STOPPED:停止。进程没有投入运行也不能投入运行。

    内核经常需要调整某个进程的状态,可以使用如下方法:
set_task_state(task, state)   // 将任务 task 的状态设置为 state

3.3 进程家族树

无论是Unix系统还是Linux系统,所有的进程都是 PID 为 1 的init进程的后代。内核在系统启动的最后阶段启动 init 进程。该进程读取系统的初始化脚本并执行其他相关程序,最终完成系统启动的整个过程。

系统中的每个进程必有一个父进程,相应的每个进程可以拥有零个或多个子进程。进程间的关系存放在进程描述符中。每个 task_struct 都包含一个指向其父进程 task_struct、叫做parent的指针,还包含一个称为children的子进程链表。所以可以通过如下代码访问父、子进程:

// 获得父进程描述符
struct task_struct *my_parent = current->parent;
// 获得子进程链表
struct task_struct *task;
struct list_head* list;
list_for_each(list, &current->children) 
{
  // task指向当前的某个子进程
  task = list_entry(list, struct task_struct, sibling);
}

其次,可以通过init_task获取 init 进程。

由于任务队列是一个双向循环链表,所以很容易通过遍历链表获取任何一个进程的相关信息。我们可以通过next_task(task)prev_task(task)这两个宏分别获取下一个进程与前一个进程。其次,for_each_process(task)宏提供了依次访问整个任务队列的能力,其源码如下:

#define for_each_process(p) for(p=&init_task;(p=next_task(p))!=&init_task;)

3.4 进程的创建

Unix将进程的创建分为两步执行:fork() 和 exec(),这与其他系统有所不同。首先,fork() 通过拷贝当前进程创建一个子进程。子进程与父进程区别仅仅在于 PID、 PPID 和某些资源的统计量(例如,挂起的信号)。exec() 函数负责读取可执行文件并将其载入地址空间开始运行。

3.4.1 写时拷贝

Linux的 fork() 使用写时拷贝页实现。内核此时并不复制整个进程地址空间,而是让父进程和子进程共享同一个拷贝。只有在需要写入的时候,数据才会被复制,从而使各个进程拥有各自的拷贝,在此之前,只是以只读方式共享。

fork() 的实际开销就是复制父进程的页表以及给子进程创建唯一的进程描述符。这种优化可以避免拷贝大量根本就不会被使用的数据。提高了进程快速执行的能力。

3.4.2 fork()

Linux通过clone()系统调用实现fork()。这个调用通过一系列的参数标志来指明父、子进程需要共享的资源。fork()、vfork() 和 __clone() 库函数都根据各自需要的参数标志去调用clone(),然后由 clone() 去调用 do_fork()。

do_fork() 完成了创建中的大部分工作,它的定义在kernel/fork.c文件中。其具体过程如下图所示:

具体的文字描述请阅读《Linux内核设计与实现》

内核有意选择子进程首先执行,因为一般子进程都会马上调用 exec() 函数,这样可以避免写时拷贝的额外开销,如果父进程首先执行的话,有可能会开始向地址空间写入。

3.4.3 vfork()

除了不拷贝父进程的页表项外,vfork() 系统调用和 fork() 的功能相同。子进程作为父进程的一个单独的线程在它的地址空间里运行,父进程被阻塞,直到子进程退出或执行 exec()。子进程不能向地址空间写入。

3.5 进程的终结

当一个进程终结时,内核必须释放它所占有的资源并把这一不幸告知父进程。该任务大部分都要靠do_exit()来完成:

具体的文字描述请阅读《Linux内核设计与实现》

线程

在传统的操作系统中,每个进程都有一个地址空间和一个控制线程。事实上,这是大部分进程的定义,在许多情况下,经常存在同一地址空间中运行多个控制线程的情形,这些线程就像是分离的进程。

1 线程模型

同一个进程内多个线程之间可以共享代码段、数据段、打开的文件等资源,但每个线程都有独立一套的寄存器和栈,这样可以确保线程的控制流是相对独立的。

如下图,为线程间共享的内容:

1.1 线程的优缺点

线程的优点

  • 一个进程中可以同时存在多个线程;
  • 各个线程之间可以并发执行;
  • 各个线程之间可以共享地址空间和文件等资源

线程的缺点

  • 当进程中的一个线程奔溃时,会导致其所属进程的所有线程奔溃

1.2 进程和线程的比较

线程与进程的比较如下:

  • 进程是资源(包括内存、打开的文件等)分配的单位,线程是 CPU 调度的单位
  • 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈
  • 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系
  • 线程能减少并发执行的时间和空间开销;

对于,线程相比进程能减少开销,体现在:

  • 线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们
    -线程的终止时间比进程快,因为线程释放的资源相比进程少很多
  • 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的
  • 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了

所以,线程比进程不管是时间效率,还是空间效率都要高

1.3 线程的上下文切换

线程是调度的基本单位,而进程则是资源拥有的基本单位。

所以,所谓操作系统的任务调度,实际上的调度对象是线程,而进程只是给线程提供了虚拟内存、全局变量等资源。

对于进程和线程:

  • 当进程只有一个线程时,可以认为进程就等于线程
  • 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源,这些资源在上下文切换时是不需要修改的

另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。

这还得看线程是不是属于同一个进程:

  • 当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样;
  • 当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据

所以,线程的上下文切换相比进程,开销要小很多。

1.4 线程的实现

线程的实现方法主要有三种:

  • 用户线程:在用户空间实现的线程,不是由内核管理的线程,是由用户态的线程库来完成线程的管理;
  • 内核线程:在内核中实现的线程,是由内核管理的线程;
  • 轻量级进程:在内核中来支持用户线程

除此之外,用户线程和内核线程之间存在三种对应关系:

多对一

一对一

多对多

1.4.1 用户线程

用户线程是基于用户态的线程管理库来实现的,那么线程控制块(Thread Control Block, TCB)也是在库里面来实现的,对于操作系统而言是看不到这个 TCB 的,它只能看到整个进程的 PCB。

所以,用户线程的整个线程管理和调度,操作系统是不直接参与的,而是由用户级线程库函数来完成线程的管理,包括线程的创建、终止、同步和调度等。

用户级线程的模型,也就类似前面提到的多对一的关系,即多个用户线程对应同一个内核线程,如下图所示:

用户线程的优点

  • 每个进程都需要有它私有的线程控制块(TCB)列表,用来跟踪记录它各个线程状态信息(PC、栈指针、寄存器),TCB 由用户级线程库函数来维护,可用于不支持线程技术的操作系统;
  • 用户线程的切换也是由线程库函数来完成的,无需用户态与内核态的切换,所以速度特别快

用户线程的缺点

  • 由于操作系统不参与线程的调度,如果一个线程发起了系统调用而阻塞,那进程所包含的用户线程都不能执行了。
  • 当一个线程开始运行后,除非它主动地交出 CPU 的使用权,否则它所在的进程当中的其他线程无法运行,因为用户态的线程没法打断当前运行中的线程,它没有这个特权,只有操作系统才有,但是用户线程不是由操作系统管理的。
  • 由于时间片分配给进程,故与其他进程比,在多线程执行时,每个线程得到的时间片较少,执行会比较慢

1.4.2 内核线程

内核线程是由操作系统管理的,线程对应的 TCB 自然是放在操作系统里的,这样线程的创建、终止和管理都是由操作系统负责。

内核线程的模型,也就类似前面提到的一对一的关系,即一个用户线程对应一个内核线程,如下图所示:

内核线程的优点

  • 在一个进程当中,如果某个内核线程发起系统调用而被阻塞,并不会影响其他内核线程的运行;
  • 分配给线程,多线程的进程获得更多的 CPU 运行时间;

内核线程的缺点

  • 在支持内核线程的操作系统中,由内核来维护进程和线程的上下问信息,如 PCB 和 TCB;
  • 线程的创建、终止和切换都是通过系统调用的方式来进行,因此对于系统来说,系统开销比较大

1.4.3 轻量级进程

轻量级进程(Light-weight process,LWP)是内核支持的用户线程,一个进程可有一个或多个 LWP,每个 LWP 是跟内核线程一对一映射的,也就是 LWP 都是由一个内核线程支持。另外,LWP 只能由内核管理并像普通进程一样被调度,Linux 内核是支持 LWP 的典型例子。

在大多数系统中,LWP与普通进程的区别也在于它只有一个最小的执行上下文和调度程序所需的统计信息。一般来说,一个进程代表程序的一个实例,而 LWP 代表程序的执行线程,因为一个执行线程不像进程那样需要那么多状态信息,所以 LWP 也不带有这样的信息。

在 LWP 之上也是可以使用用户线程的,那么 LWP 与用户线程的对应关系就有三种:

  • 1 : 1,即一个 LWP 对应 一个用户线程
  • N : 1,即一个 LWP 对应多个用户线程
  • N : N,即多个 LMP 对应多个用户线程

2 线程在Linux中的实现

Linux 实现线程的机制非常独特。从内核的角度来说,它并没有线程这个概念。Linux把所有的线程都当作进程来实现。内核并没有准备特别的调度算法或是定义特别的数据结构来表征线程。相反,线程仅仅被视为一个与其他进程共享某些资源的进程。每个线程都拥有唯一隶属于自己的 task_struct,所以在内核中,它看起来就像是一个普通的进程。

2.1 线程的创建

线程的创建和普通进程的创建类似,只不过在调用 clone() 的时候需要传递一些参数标志来指明需要共享的资源:

clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);

上述代码的结果和fork()差不多,知识父子俩共享地址空间、文件系统资源、文件描述符和信号处理程序。对比普通的 fork() 和 vfork() 实现:

clone(SIGCHLD, 0);  // fork
cloen(CLONE_VFORK | CLONE_VM | SIGCHLD, 0);  // vfork

具体的参数标志含义请阅读《Linux内核设计与实现》

2.2 内核线程

内核线程和普通的进程间的区别在于内核线程没有独立的地址空间。它们旨在内核空间中运行,从来不切换到用户空间去。内核线程和普通进程一样,可以被调度,也可以被抢占。

参考书籍

《现代操作系统》
《Unix环境高级编程》
《Linux/Unix系统编程手册》
《深入理解Linux内核》
《Linux内核设计与实现》

posted @ 2021-04-14 17:30  Leaos  阅读(128)  评论(0编辑  收藏  举报