进程与线程¶
约 4977 个字 47 行代码 7 张图片 预计阅读时间 25 分钟
什么是进程¶
进程的概念与特征¶
进程
进程可以说是计算机中某个程序在运行时的一个实例.举个例子,程序就是一道菜的菜谱,而进程就是你实际做菜的过程.每次你根据菜谱做菜,你都在创建一个新的进程.
官方话的定义是:进程是进程实体的运行过程,是系统进行资源分配和调度的一个独立单位。
系统资源实际上是CPU,内存等为这个进程分配的时间
进程在内存中运行,它需要硬盘上的一段代码之类的存在,来执行其任务;执行任务中可能会产生各种数据要保存;另外,操作系统也希望了解进程的状态,以便进行调度和管理.
基于以上需求,进程一般由如下三部分组成:
-
进程控制块(PCB):PCB是进程的一部分,存在于内存中,是进程中最重要的部分.
- PCB不断存储着进程的状态,当前处理器各寄存器的值等,以便于操作系统对进程进行调度和管理.
-
程序段(text segment):程序段是进程的一部分,包含进程运行所需的机器指令。运行时,程序段会被加载到内存中供 CPU 执行。
- 多个进程可以共享同一份程序段,也即,多个进程可以运行同一份代码.
-
数据段(data segment):数据段是进程的一部分,存在于内存中,用于存储进程运行时所需的各种数据.
- 数据段包含了进程运行所需的全局变量,静态变量等数据.
-
Stack(栈):栈是进程的一部分,存在于内存中,存储函数调用时的临时数据,如函数参数,返回地址,局部变量等.
-
Heap(堆):堆是进程的一部分,存在于内存中,用于动态分配内存.进程运行时,可以通过系统调用向操作系统申请分配一定大小的内存,这部分内存就来自堆.
进程的状态与转换¶
电脑的CPU和内存等资源是有限的,所以,操作系统需要对进程进行调度和管理.进程在其生命周期中会经历不同的状态,这些状态包括:
-
运行态(Running):进程正在使用CPU执行其任务.如果是单核CPU,同一时间只能有一个进程处于运行态.
-
就绪态(Ready):进程获得了除CPU以外所有需要的资源,只要获得了CPU就可以运行.所有的就绪态进程组成一个就绪队列,等待CPU的调度.
-
阻塞态(Blocked):也称等待态(Waiting).进程因为缺少某项资源(不包括CPU)而无法继续执行,只能等待.例如,进程需要等待I/O操作完成,或者等待某个信号量等. 阻塞的进程也会排成一个阻塞队列,有时也会根据不同的阻塞原因分成多个阻塞队列.
-
创建态(New):进程刚被创建,还没有进入就绪队列.一个进程的创建,需要先申请一个PCB,在PCB中初始化进程的各种信息,然后还需要等待分配所需的各种系统资源,最后才能进入就绪队列.
-
终止态(Terminated):进程完成了其任务,或者因为某种原因被强制终止,进入终止态.进程进入终止态后,操作系统会回收其所占用的资源,并将其PCB等信息从内存中删除.
为什么我们需要区分就绪态与阻塞态
在系统资源中,CPU资源和其他资源都不一样.CPU是不断轮转的,一个进程可能每次只能分到几毫秒的CPU时间片,然后就要把CPU让给其他进程使用.进程会经常在就绪态和运行态之间切换.
而阻塞态等待的资源,可能需要等待很长时间,例如等待用户输入,等待网络数据传输等.进程一旦进入阻塞态,就不会再自动变成就绪态,而是要等到所等待的资源变得可用时,操作系统才会把它从阻塞态变成就绪态.
需要注意的是,一个进程从运行态变成阻塞态,是因为它主动请求某项资源,而不是因为操作系统强制它放弃CPU.如果操作系统强制一个运行态进程放弃CPU,那么这个进程会变成就绪态,而不是阻塞态.
而一个进程从阻塞态变成运行态,往往是一个被动的过程,因为它等待的资源变得可用,操作系统才会把它变成就绪态,然后再调度它进入运行态.
进程管理¶
进程的创建¶
一个进程可以创建另一个进程,这个新创建的进程称为子进程,创建它的进程称为父进程.一个进程可以有多个子进程,但每个子进程只有一个父进程.
这棵树的根是systemd进程,它是所有进程的祖先进程.它的pid一般是1.在Linux系统中,systemd进程负责初始化系统,启动各种服务和守护进程,并管理系统的运行状态.
使用fork()创建进程
在Unix/Linux系统中,创建进程通常使用fork()系统调用.这个调用会创建一个与当前进程几乎完全相同的子进程,子进程会继承父进程的代码段,数据段,堆栈等.
调用fork()时,操作系统内核会执行以下操作:
-
分配新的内存和内核数据结构:为子进程分配一个新的、唯一的进程标识符(PID)。
-
复制父进程的数据:将父进程的整个地址空间(包括代码段、数据段、堆、栈等)的内容复制到为子进程分配的内存中。
-
继承文件描述符:子进程会继承父进程所有打开的文件描述符。这意味着如果父进程打开了一个文件,子进程也会拥有指向同一个文件的文件描述符,并且它们共享相同的文件偏移量。
-
将子进程放入就绪队列:创建完成后,子进程被置于进程就绪队列中,等待 CPU 调度执行。
fork的神奇之处就在于,它会返回两次.就像我们上面说的,进程是"动起来"的程序,所以,当fork()被调用时,操作系统会创建一个新的子进程,这个子进程会从fork()调用点开始执行,就像父进程一样.
但不同的在于,这两个进程中fork的返回值不同,父进程中返回的是子进程的PID,而子进程中返回的是0.这样,父进程和子进程就可以通过检查fork()的返回值来区分自己是哪个进程.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(int argc, char *argv[]) {
pid_t pid;
pid = fork(); // 创建子进程
if (pid < 0) {
// 创建失败
fprintf(stderr, "Fork Failed\n");
exit(-1);
} else if (pid == 0) {
// 子进程
printf("This is the child process. Running 'ls':\n");
execlp("/bin/ls", "ls", NULL);
// execlp 失败才会执行下面这行
fprintf(stderr, "execlp Failed\n");
exit(-1);
} else {
// 父进程
wait(NULL); // 等待子进程结束
sleep(1);
printf("Child Complete\n");
exit(0);
}
}
在这个例子中,当fork()被调用时,操作系统会创建一个新的子进程.父进程和子进程都会继续执行main()函数中的代码,但它们会根据fork()的返回值执行不同的分支.
这里,wait(NULL)是父进程调用的,它会阻塞父进程,直到子进程结束.这样,父进程就可以等待子进程完成它的任务,然后再继续执行.不然的话,父进程和子进程可能会并发执行.
Copy on Write
在上面我们提到,fork()会复制父进程的整个地址空间到子进程中.但实际上,如果真的这样做,那么每次fork的开销都会很大.因此,现代操作系统通常使用一种叫做"写时复制"(Copy on Write, COW)的技术来优化这个过程.
写时复制的基本思想是,在fork()之后,父进程和子进程会共享同一份内存页面(只读),直到其中一个进程尝试修改这部分内存.当一个进程尝试写入共享的内存页面时,操作系统会为该进程创建该页面的一个私有副本,并将修改操作应用到这个副本上.这样,只有在实际需要修改内存时,才会进行内存复制,从而大大减少了fork()的开销.
进程的终止¶
当进程:
-
正常完成任务
-
被其他进程强制终止(如使用
kill命令) -
出现严重错误(如非法内存访问等)
就会进入终止态.该进程会调用exit()系统调用,通知操作系统它已经结束.操作系统会回收该进程占用的资源,或直接释放,或被父进程通过调用wait()系统调用来回收.
但如果父进程没有调用wait()来回收子进程,那么子进程就会变成僵尸进程(Zombie).僵尸进程仍然占用系统资源,但它已经不再执行任何任务.如果一个父进程创建了很多子进程,但没有及时回收它们,那么这些僵尸进程会占用大量系统资源,可能导致系统性能下降.
在现代的linux系统中,当这个僵尸进程的父进程终止时,僵尸进程会被init进程收养,然后init进程会定期调用wait()来回收这些僵尸进程,从而防止僵尸进程无限制地积累.
僵尸进程和孤儿进程是两种不同的概念:
-
僵尸进程:是指已经终止但其父进程尚未调用
wait()来回收其资源的进程.僵尸进程仍然占用系统资源,但它已经不再执行任何任务. -
孤儿进程:是指其父进程已经终止,但它本身仍在运行的进程.孤儿进程会被
init进程收养,并由init进程负责回收其资源.
进程通信(Interprocess Communication)¶
在实际操作中,不同进程之间往往需要交换数据或协同工作.为此,操作系统提供了多种进程间通信(IPC)机制,包括:
-
共享内存(Shared Memory):多个进程可以访问同一块内存区域,从而实现高速数据交换.这种方式效率较高,但需要进程自行管理同步和互斥.
-
消息传递(Message Passing):进程可以通过消息队列发送和接收消息.消息队列由操作系统管理,可以实现进程间的异步通信.
-
直接通信:发送方直接把消息发送给接收方,接收方直接从发送方接收消息.这种方式要求发送方和接收方必须知道对方的标识符(如PID等).
-
间接通信:发送方把消息发送到一个中间的消息队列(称为信箱),接收方从消息队列中接收消息.这种方式不要求发送方和接收方知道对方的标识符,只需要知道消息队列的标识符即可.
-
-
管道(Pipes):管道是一种半双工的通信方式,允许一个进程将数据写入管道,另一个进程从管道读取数据.匿名管道通常用于有亲缘关系的进程之间的通信.
- 管道满之前,读取进程会被阻塞;管道空之前,写入进程会被阻塞.
-
信号(Signals):信号是一种异步通知机制,允许进程向另一个进程发送信号,以通知某个事件的发生.信号通常用于处理异步事件,如中断等.
进程调度¶
在进程调度中,通常有三个队列
就绪队列(Ready queue):包含所有处于就绪态的进程,等待CPU调度,位于内存中.
阻塞队列(Device queues):包含所有处于阻塞态的进程,等待某个事件发生.
总队列(Job queue):包含所有创建但尚未进入就绪队列的进程.
一个进程从创建到完成,往往要经历三种调度:
-
长程调度(Long-term scheduling):从总队列中选择进程进入就绪队列,为它们分配内存,I/O等资源,让它们能够参与竞争CPU.长程调度的频率较低,通常在几秒到几分钟之间.
-
短程调度(Short-term scheduling):从就绪队列中选择一个进程进入运行态,分配CPU时间片.短程调度的频率较高,通常在几毫秒到几百毫秒之间.
-
中程调度(Medium-term scheduling):将某些处于阻塞状态的进程从内存中换出到磁盘上,以释放内存资源,或者将某些进程从磁盘上换入到内存中,以便它们能够参与竞争CPU.对于那些被挂起到外存到进程,我们也可以叫它们挂起态.中程调度的频率介于长程调度和短程调度之间,通常在几秒到几十秒之间.
上下文切换
当操作系统从一个进程切换到另一个进程时,需要保存当前进程的状态(寄存器值,程序计数器等)到它的PCB中,然后加载下一个进程的状态到CPU寄存器中.这个过程称为上下文切换(Context Switch).
上下文切换是一个开销较大的操作,因为它涉及到保存和加载寄存器状态,更新内存映射等.
线程¶
线程简单理解就是轻量化的进程.一个进程可以包含多个线程,这些线程共享进程的资源(如内存空间,文件描述符等),但每个线程有自己的寄存器状态和栈空间.
-
进程是资源分配与调度的基本单位
-
线程是CPU调度与执行的基本单位
相比于进程,线程的优点包括:
-
创建和销毁开销较小:创建和销毁线程的开销远小于进程,因为线程共享进程的资源,不需要为每个线程分配独立的内存空间等.
-
上下文切换开销较小:线程共享进程的内存空间等资源,不需要切换内存映射等.
-
资源共享更方便:线程共享进程的内存空间等资源,线程之间可以直接访问共享数据,不需要使用复杂的进程间通信机制.
线程的状态与转换¶
线程有以下几种状态:
-
执行态(Running):线程正在使用CPU执行其任务.
-
就绪态(Ready):线程获得了除CPU以外所有需要的资源,只要获得了CPU就可以运行.
-
阻塞态(Blocked):线程因为缺少某项资源(不包括CPU)而无法继续执行,只能等待.
这几种状态的转换逻辑和进程是类似的
线程的控制¶
正如进程有进程控制块(PCB)一样,线程也有线程控制块(TCB).TCB包含了线程的各种信息,包括:
-
线程标识符(TID)
-
寄存器状态
-
线程状态
-
优先级
-
栈指针
要注意的是,统一进程中不同线程所共享的只有进程的资源(如内存空间,文件描述符等),而每个线程有自己的寄存器状态和栈空间.
为了创建一个线程,一般会使用pthread_create()函数.这个函数会创建一个新的线程,并指定该线程要执行的函数.
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
void* thread_function(void* arg) {
printf("Hello from the new thread!\n");
return NULL;
}
int main() {
pthread_t thread;
// 创建一个新线程
if (pthread_create(&thread, NULL, thread_function, NULL) != 0) {
fprintf(stderr, "Error creating thread\n");
return 1;
}
// 等待线程结束
pthread_join(thread, NULL);
printf("Thread has finished execution.\n");
return 0;
}
线程在终止时,一般不会立刻释放其资源,而是进入一个类似僵尸进程的状态,直到另一个线程调用pthread_join()来回收其资源,或者有线程重新调用了这个被终止的线程.
线程的实现方式¶
线程的实现方式主要有两种:用户级线程(User-level threads)和内核级线程(Kernel-level threads).
用户级线程与内核级线程¶
用户级线程是在用户空间中实现的线程管理.用户级线程库负责创建,调度和管理线程,而操作系统内核并不知道这些线程的存在.
而内核级线程则是由操作系统内核直接管理的线程.内核级线程由内核负责创建,调度和管理,时间片直接以线程为单位分配.
进程是由内核创建,管理,并调度的,然而,进程中的线程却可以分为用户级和内核级两种.
这种区别,就形成了用户级多线程和内核级多线程两种模式
-
用户级多线程(User-level multithreading):多个用户级线程由一个内核级进程来管理.内核只知道这个进程的存在,而不知道进程中的线程.用户级线程库负责线程的创建,调度和管理.
-
优点:创建和销毁线程的开销较小,上下文切换开销较小,资源共享更方便.
-
缺点:如果一个线程阻塞了,那么整个进程都会被阻塞,因为内核只知道进程的存在;用户级线程库需要自己实现调度算法
-
-
内核级多线程(Kernel-level multithreading):内核直接管理多个线程,每个线程都有自己的线程控制块(TCB).内核负责线程的创建,调度和管理,时间片直接以线程为单位分配.
-
优点:如果一个线程阻塞了,其他线程仍然可以继续执行;内核可以更好地利用多核CPU,实现真正的并行执行.
-
缺点:创建和销毁线程的开销较大,上下文切换开销较大
-
-
混合多线程(Hybrid multithreading):结合了用户级多线程和内核级多线程的优点.多个用户级线程映射到多个内核级线程上,内核负责调度内核级线程,而用户级线程库负责调度用户级线程.
-
优点:结合了用户级和内核级多线程的优点,提高了系统的并发性能和资源利用率.
-
缺点:实现复杂度较高,需要协调用户级线程库和内核之间的调度.
-
总结
用户级多线程(多对一模型)与内核级多线程(一对一模型)的核心区别在于操作系统内核的“可见性”,这直接决定了程序能否实现并行。
在用户级多线程中,多个用户线程被映射到一个内核线程上。内核只认识这一个内核线程,因此在多核CPU环境下,它最多也只会将这一个内核线程分配到一个CPU核心上。这意味着,所有用户线程只能在这一个核心上并发(Concurrency)执行(即宏观上同时,微观上交替),无法实现并行(Parallelism)(即真正的多核同时执行)。
而在内核级多线程中,每一个用户线程都对应一个独立的内核线程。内核“看见”并管理所有这些内核线程,因此它可以将它们调度到多个不同的CPU核心上。这使得该模型能够实现真正的并行。当线程数多于核心数时,内核会在每个核心上再进行并发调度。






