0%

进程线程与纤程

进程

进程状态

线程可以处于以下几种状态

  • 新生状态:表示一个线程刚被创建出来还未完成初始化,不能被调度执行。在初始化完成以后,进入预备状态。
  • 预备状态:表示进程可以被调度运行,但还未被调度器选择。在被调度器选择以后进入运行状态。
  • 运行状态:表示线程正在CPU上运行。当运行一段时间后,调度器可以将其重新放回调度队列之中,就会迁移至预备状态。进程结束时迁移至终止状态。当需要等待外部事件时,迁移至阻塞状态。
  • 阻塞状态:表示线程需要等待外部事件,暂时无法被调度。当线程等待的外部事件完成以后,迁移至预备状态。
  • 终止状态:表示进程已经完成执行,不会再被调度。

进程状态

进程内存空间布局

进程拥有独立的虚拟内存空间。包括以下几个部分

  • 用户栈:保存进程需要使用的各种临时数据。其扩展方向为自顶向下,栈底在高地址上。
  • 代码库:进程执行所依赖的共享的代码库(比如libc),这些地址会映射在用户栈下方并标记为只读。
  • 用户堆:管理进程动态分配的内存。其扩展方向为自底向上,堆顶在高地址上。
  • 数据与代码段:保存全局变量的值以及进程执行所需要的代码,处于较低地址。
  • 内核部分:每个进程的虚拟地址空间都映射了相同的地址空间,处于进程地址空间的最顶端。只有当程序进入内核态时才能访问内核内存。

进程内存空间布局

进程控制块

在内核中,每一个进程都通过一个名为进程控制块的数据结构来保存相关状态。在Linux系统中PCB对应的数据结构为task_struct,定义在[sched.h]文件中。下面代码列举了几个重要字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct task_struct{
/* 进程状态 */
volatile long state;
/* 虚拟内存状态 */
struct mm_struct *mm;
/* 进程标识符 */
pid_t pid;
/* 进程组标识符 */
pid_t tgid;
/* 进程间关系 */
struct task_struct __rcu *parent;
struct list_head children;
/* 打开的文件 */
struct files_struct *files;
/* 省略 */
...
}

上下文切换

进程的上下文包括进程运行时的寄存器状态,能够用于保存和恢复一个进程在处理器上运行的状态。当操作系统需要切换当前执行的进程时,就会使用上下文切换机制,将起一个进程的寄存器保存到PCB之中,从而切换该线程执行。

下图展示了线程的上下文切换过程,当进程1用于中断或者系统调用进入内核以后,操作系统将进行上下文切换,保存将线程1的上下文保存到对应的PCB之中去,之后取出线程2对应的PCB的上下文,将其中的值恢复到寄存器中,最后操作系统回到用户态,继续进程2的运行。

进程上下文切换

Linux进程操作

进程创建fork

在Linux中使用fork函数创建新进程,对于父进程fork函数返回当前进程的PID,子进程的返回值为0。以下是fork的一个示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>
#include <unistd.h>

int main() {
pid_t rc = fork();
if (rc < 0) {
std::cout << "fork 失败" << std::endl;
} else if (rc == 0) {
std::cout << "当前为子进程, pid: " << getpid() << std::endl;
} else {
std::cout << "当前为父进程, pid: " << getpid() << std::endl;
}
}

进程执行exec

exec由一系列接口组成,存在多个变种,功能最全面的是execve:

1
2
#include<unistd.h>
int execve (const char *__path, char *const __argv[], char *const __envp[])

其共接受三个参数,第一个参数path是进程执行需要载入的可执行文件的路径,第二个参数argv是进程执行所需的参数,第三个参数envp是进程的环境变量,一般以键值对字符串的形式传入。

进程管理

在Linux中,进程都由fork创建,操作系统会以fork作为线索记录进程之间的关系,PID会记录每个进程的父进程和子进程。

处于进程树根部的是init进程,是操作系统创建的第一个进程,之后所有的进程都是由他直接或间接创建出来的。

为了方便应用程序进行管理,内核还定义了多个进程组合而成的进程组和会话。

进程组是进程的集合,由一个或多个进程组成。task_struct结构中的tgid即为进程对应的进程组标识符。如果进程想要脱离当前的进程组,可以通过调用setpgid修改自己所在的进程组。

会话是进程组的集合。由一个或多个进程组构成。会话将进程组根据执行状态分为了前台进程组、后台进程组。控制终端是会话与外界进行交互的窗口,负责接受由用户发来的输入。

进程监控wait

进程可以通过wait操作来对子进程进行监控。wait函数有很多变种,以waitpid为例:

1
2
#include<sts/wait.h>
__pid_t waitpid (__pid_t __pid, int *__stat_loc, int __options);

其中第一个参数表示需要等待的子进程,第二个参数用来表示子进程的状态,第三个参数表示选项。wait可以回收已经运行结束的子进程和释放资源。使用wait的一个示例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <unistd.h>
#include <sys/wait.h>

int main() {
pid_t rc = fork();
if (rc < 0) {
std::cout << "fork 失败" << std::endl;
} else if (rc == 0) {
std::cout << "子进程退出" << std::endl;
} else {
int status = 0;
if (waitpid(rc, &status, 0) < 0) {
std::cout << "waitpid 失败" << std::endl;
} else {
if (WIFEXITED(status)) {
std::cout << "父进程: 子进程退出" << std::endl;
}
}
}
}

如果父进程没有调用wait操作,就算子进程已经终止,所占用的资源不会完全释放,这种进程被称为僵尸进程。内核会为僵尸进程保留其进程描述符和中止时信息,以便父进程在调用wait时监控子进程的状态。如果一个进程大量创建子进程且不调用wait,那么会导致僵尸进程占据可用的PID,使得后续的fork因为内核资源不足而失败。直到父线程退出,那么子进程将不会再被父进程使用,所有父进程创建的僵尸进程都会被内核的init进程调用wait进行回收。

线程

随着计算机技术的发展,进程显得过于笨重,主要体现在以下几点。

  1. 创建进程的开销较大,需要完成创建独立的地址空间、载入数据和代码段、初始化堆等步骤。
  2. 进程用于独立的地址空间,在进程间进行数据的共享和同步比较麻烦

因此提出了在进程之内添加可独立执行的单元,他们共享进程的地址空间,但又各自保存运行时的上下文状态,称为线程。

线程地址空间布局

进程为每个线程都准备了不同的栈,供它们存放临时的数据。进程除了栈只玩的所有其他区域都由该进程的所有线程共享,包括堆数据段以及代码段。

线程控制块

与进程类似,线程也有自己的线程控制块(TCB),用于保存与自身相关的信息。

纤程

随着计算机的发展,应用程序变得越来越复杂,在复杂。与操作系统调度器相比,应用程序对于线程的语义以及执行状态更加了解,因此可以做出更加的调度策略。此外用户态线程更加轻量级,创建与上下文切换的开销更小。在这样的背景下,操作系统开始提供对用户态线程(即纤程)的支持。

纤程的上下文切换的触发机制与内核态线程有较大不同。内核态线程的切换时强制的,因此称为抢占式多任务处理。而纤程在遇到阻塞时会放弃CPU,允许其他纤程的调度,这种调度方式称为合作式多任务处理。

除了操作系统以外,很多程序语言也提供了对于纤程的支持,方便应用程序创建和管理轻量级的上下文,从而支持更复杂的应用。一般将程序语言提供的纤程支持称为协程。

POSIX的协程支持

POSIX中用来支持纤程的是ucontext.h中的接口。

1
2
3
4
5
6
7
8
9
10
11
12
#include<ucontext.h>
/* 保存当前上下文 */
int getcontext (ucontext_t *__ucp);

/* 切换到另一个上下文 */
int setcontext (const ucontext_t *__ucp);

/* 切换上下文 */
int swapcontext (ucontext_t *__restrict __oucp,const ucontext_t *__restrict __ucp);

/* 创建全新的上下文 */
void makecontext (ucontext_t *__ucp, void (*__func) (void),int __argc, ...);

下面的代码实现了生产者消费者模型。在主函数中使用makecontext创建了两个上下文context1和context2,分别用于调用produce和consume函数。此后程序通过setcontext不断在produce和consume函数之间跳转,不断重复的生产和消费。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <iostream>
#include <sys/wait.h>
#include <ucontext.h>

ucontext_t context1, context2;
int current = 0;

void produce() {
current++;
std::cout << "生产者当前值为" << current << std::endl;
setcontext(&context2);
}

void consumer() {
std::cout << "消费者当前值为" << current << std::endl;
setcontext(&context1);
}

int main() {
char iteratorStack1[SIGSTKSZ];
char iteratorStack2[SIGSTKSZ];
getcontext(&context1);
context1.uc_link = nullptr;
context1.uc_stack.ss_sp = iteratorStack1;
context1.uc_stack.ss_size = sizeof(iteratorStack1);
makecontext(&context1, produce, 0);

getcontext(&context2);
context2.uc_link = nullptr;
context2.uc_stack.ss_sp = iteratorStack2;
context2.uc_stack.ss_size = sizeof(iteratorStack2);
makecontext(&context2, consumer, 0);

setcontext(&context1);
return 0;
}

通过利用纤程,可以对生产者消费者这样的多个模块协作的场景进行有效的支持。在生产者完成任务以后,可以立即切换到消费者继续执行。由于该切换由用户态线程库完成,不需要操作系统调用,可以达到很好的性能。