进程和线程

定义

  1. 进程是资源分配的基本单位,在程序运行时创建
  2. 线程是程序执行的最小单位,是进程的一个执行流,一个进程由多个线程组成

区别

  1. 进程是资源分配的最小单位,线程是进程执行的最小单位,也是cpu调度的最小单位。两者都可以并发执行
  2. 进程拥有自己的独立地址空间,线程没有,线程必须依赖进程的地址空间。进程的创建会建立数据表来维护代码段、堆栈段和数据段,这种操作比较昂贵,开销比较大,线程的创建则是共享进程中的数据,开销比较小
  3. 线程之间的通信比较方便,同一进程下的线程共享全局变量和静态变量等数据。进程之间的通信则比较复杂(IPC)
  4. 进程切换的开销大于线程
  5. 每个进程都有一个程序执行的入口,但是线程不能独立运行
  6. 线程执行开销小,但是不利于资源的保护和管理。进程执行的开销大,但利于资源的保护和管理

进程状态

创建、就绪、运行、阻塞、终止

创建

一个应用程序从系统上启动,首先就是进入创建状态,需要获取系统资源创建进程管理块(PCB:Process Control Block)完成资源分配。

就绪

在创建状态完成之后,进程已经准备好,但是还未获得处理器资源,无法运行。

运行

获取处理器资源,被系统调度,开始进入运行状态。如果进程的时间片用完了就进入就绪状态。

阻塞

在运行状态期间,如果进行了阻塞的操作,如耗时的I/O操作,此时进程暂时无法操作就进入到了阻塞状态,在这些操作完成后就进入就绪状态

终止

进程结束或者被系统终止,进入终止状态

进程状态

创建进程的方式

fork

fork() 是 Linux 最经典的创建进程方式。它会完整复制当前进程(代码段、数据段、堆栈等,采用写时复制 CoW 技术),创建一个子进程。

特点: 一次调用,两次返回。在父进程中返回子进程的 PID,在子进程中返回 0。

#include <iostream>
#include <unistd.h>
#include <sys/types.h>

int main() {
pid_t pid = fork();

if (pid < 0) {
std::cerr << "Fork 失败!" << std::endl;
return 1;
} else if (pid == 0) {
// 子进程代码
std::cout << "子进程,PID: " << getpid() << std::endl;
} else {
// 父进程代码
std::cout << "父进程,子进程 PID 是: " << pid << std::endl;
}
return 0;
}

fork() + exec 族函数

创建全新进程

#include <iostream>
#include <unistd.h>

int main() {
pid_t pid = fork();
if (pid == 0) {
// 替换子进程映像,执行 "ls -l"
execl("/bin/ls", "ls", "-l", NULL);
// 如果 execl 执行成功,下面的代码绝对不会被执行
std::cerr << "执行失败!" << std::endl;
}
return 0;
}

标准库与第三方库

  1. std::system():

    #include <cstdlib>

    int main() {
    // Linux 下列出目录,Windows 下可改为 system("dir");
    int result = std::system("ls -la");
    return 0;
    }

    优点: 极其简单,全平台通用。
    缺点: 效率低下(多启动了一个 Shell 进程);父进程会同步阻塞,直到子进程执行完毕;存在命令注入的安全风险。

  2. boost::process:

    #include <boost/process.hpp>
    #include <iostream>

    namespace bp = boost::process;

    int main() {
    // 异步拉起一个进程,自动适配当前操作系统
    bp::child c("g++", "--version");
    c.wait(); // 等待结束
    std::cout << "退出码: " << c.exit_code() << std::endl;
    return 0;
    }

    boost::process(C++17 风格)是目前 C++ 社区最优雅、最强大的跨平台进程管理方案。它支持异步、管道重定向、输入输出流绑定等。

进程间通信的方式

管道(pipe)

这种通讯方式有两种限制:

  1. 半双工的通信,数据只能单向流动
  2. 只能在具有亲缘关系的进程间使用(父子进程)

流管道 s_pipe

去除了第一种限制,可以双向传输(全双工)

命名管道name_pipe

克服了管道没有名字的限制,还运行无亲缘关系的进程间通信(命名管道也叫FIFO)

信号量semophore

信号量是一个计数器,可以用来控制多个进程对共享资源的访问,防止资源竞争,主要作为进程间或者同一进程下多个线程之间的同步手段

消息队列

消息队列是由消息组成的链表,存放在内核中并由消息队列标识符标识。消息队列是消息的链接表,包括Posix消息队列、system V消息队列。有足够权限的进程可以向队列中添加消息,被赋予读权限的进程则可以读走消息队列中的消息。消息队列克服了信号承载信息量少,管道只能承载无格式字节流以及缓冲区大小受限等缺点

信号singnal

信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生,主要作为进程间以及同一进程下不同线程之间的同步手段

共享内存(shared memory)

共享内存就是映射一段能被其他进程访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的IPC方式,是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,比如信号量来配合使用,实现进程间的同步和通信

共享内存几乎永远不会单独使用。它必须搭配一种同步锁机制来使用,最经典的就是 信号量(Semaphore) 或 互斥锁(Mutex)。

使用 Mutex 相比于 Semaphore(信号量)的优缺点

  1. 机制不同(互斥 vs 同步):
  • Mutex(互斥锁) 的核心是排他性。它确保同一时间只有一个进程能进临界区。它强调的是“别跟我抢”。
  • Semaphore(信号量) 往往用于通知与协作。比如上一个例子中,父进程在等子进程发信号(sem_post),这是一种“生产->消费”的时序控制。
  1. 死锁风险(Mutex 致命缺点):
  • 如果进程 A 拿到了共享内存里的 Mutex,结果进程 A 在临界区里因为异常崩溃(Crash)了,由于它没来得及执行 unlock,这个锁就永远死锁在共享内存里了。进程 B 将永远卡在 lock() 处。
  • 解决办法: 必须在初始化互斥锁属性时加入 鲁棒锁(Robust Mutex) 属性:pthread_mutexattr_setrobust(&attr, PTHREAD_MUTEX_ROBUST);。这样如果持有锁的进程挂了,其他进程在加锁时会收到 EOWNERDEAD 错误,从而有机会接管并清理现场。
  1. 性能:
  • 在没有冲突(没有两个进程同时抢锁)的情况下,跨进程 Mutex 通常在用户态就能完成快速判断,不需要陷入内核,性能极其强悍。

套接字(socket)

套解字也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同机器间的 进程通信。

进程间通信方式优缺点总结

通信方式 优点 缺点
管道(pipe) 实现简单、开销低、适合父子进程快速通信 半双工(单向)、通常仅限有亲缘关系进程、只传字节流
流管道(s_pipe) 支持全双工通信,比普通管道更灵活 仍偏本机通信,消息边界需要自行设计
命名管道(FIFO) 可用于无亲缘关系进程,使用方便(有名字) 性能和扩展性一般,不适合复杂高并发场景
信号量(Semaphore) 适合同步与互斥,可控制多个进程对共享资源访问 主要用于同步而非传数据,使用不当易死锁/饥饿
消息队列(Message Queue) 按消息传输、支持结构化数据、解耦发送与接收 内核维护和拷贝有开销,队列容量受限
信号(Signal) 轻量快速,适合事件通知(如中断、退出) 携带信息少,不适合复杂数据通信
共享内存(Shared Memory) IPC中速度最快,适合大数据高吞吐 必须配合同步机制(如信号量/互斥锁),并发控制复杂,易竞态
套接字(Socket) 通用性最强,可本机也可跨主机通信 编程复杂度高,网络场景延迟与协议开销更大

选型建议

  • 追求最高性能、大数据传输:共享内存 + 同步机制
  • 需要结构化异步消息:消息队列
  • 仅做事件通知:信号
  • 父子进程简单通信:管道
  • 跨机器通信:套接字

线程间同步

  1. 临界区:
    • 通过对多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问。在任意时刻只允许一个线程访问共享资源,如果有多个线程试图访问共享资源,那么当有一个线程进入后,其他试图访问共享资源的线程将会被挂起,并一直等到进入临界区的线程离开,临界在被释放后,其他线程才可以抢占。
  2. 互斥量:
    • 为协调对一个共享资源的单独访问而设计,只有拥有互斥量的线程,才有权限去访问系统的公共资源,因为互斥量只有一个,所以能够保证资源不会同时被多个线程访问。互斥不仅能实现同一应用程序的公共资源安全共享,还能实现不同应用程序的公共资源安全共享。
  3. 信号量:
    • 为控制一个具有有限数量的用户资源而设计。它允许多个线程在同一个时刻去访问同一个资源,但一般需要限制同一时刻访问此资源的最大线程数目
  4. 事件
    • 用来通知线程有一些事件已发生,从而启动后继任务的开始

线程池

  1. 设置一个生产者消费者队列作为临界资源
  2. 创建n个线程,加锁去队列里取任务
  3. 当队列为空时,所有线程阻塞
  4. 当生产者队列来了一个任务后,先对队列加锁,把任务挂到队列里,然后使用条件变量通知阻塞中的一个线程

堆和栈

  1. 代码段
  2. 数据段
  3. bss段

并发和互斥

并发,指的是多个执行单元同时、并行被执行,而并发的执行单元对共享资源(硬件资源和软件上的全局变量、静态变量等)的访问则很容易导致竞态。解决竞态问题的途径是保证对共享资源的互斥访问,所谓互斥访问就是指一个执行单元在访问共享资源的时候,其他的执行单元都被禁止访问。访问共享资源的代码区域被称为临界区,临界区需要以某种互斥机制加以保护。中断屏蔽,原子操作,自旋锁,和信号量都是linux设备驱动中可采用的互斥途径。

自旋锁和信号量

  1. 由于争用信号量的进程在等待锁重新变为可用时会睡眠,所以信号量适用于锁会被长时间持有的情况。

  2. 相反,锁被短时间持有时,使用信号量就不太适宜了,因为睡眠引起的耗时可能比锁被占用的全部时间还要长。

  3. 由于执行线程在锁被争用时会睡眠,所以只能在进程上下文中才能获取信号量锁,因为在中断上下文中(使用自旋锁)是不能进行调度的。

  4. 可以在持有信号量时去睡眠(当然你也可能并不需要睡眠),因为当其它进程试图获得同一信号量时不会因此而死锁,(因为该进程也只是去睡眠而已,而你最终会继续执行的)。

  5. 在你占用信号量的同时不能占用自旋锁,因为在你等待信号量时可能会睡眠,而在持有自旋锁时是不允许睡眠的。

  6. 信号量锁保护的临界区可包含可能引起阻塞的代码,而自旋锁则绝对要避免用来保护包含这样代码的临界区,因为阻塞意味着要进行进程的切换,如果进程被切换出去后,另一进程企图获取本自旋锁,死锁就会发生。

  7. 信号量不同于自旋锁,它不会禁止内核抢占(自旋锁被持有时,内核不能被抢占),所以持有信号量的代码可以被抢占,这意味着信号量不会对调度的等待时间带来负面影响。

  8. 信号量不能用于中断中,因为信号量会引起休眠,中断不能休眠。自旋锁可以用于中断。在获取锁之前一定要先禁止本地中断(也就是本CPU中断,对于多核SOC来说会有多个CPU核),否则可能导致锁死现象的发生。

读写锁

当临界区的一个文件可以被同时读取,但是并不能被同时读和写。如果一个线程在读,另一个线程在写,那么很可能会读取到错误的不完整的数据。读写自旋锁是可以允许对临界区的共享资源进行并发读操作的。但是并不允许多个线程并发读写操作。

死锁

多个并发进程因争夺系统资源而产生相互等待的现象。即:一组进程中的每个进程都在等待某个事件发生,而只有这组进程中的其他进程才能触发该事件,这就称这组进程发生了死锁。
产生死锁的本质原因为: 1. 系统资源有限。 2. 进程推进顺序不合理。

死锁的必要条件

  1. 互斥:某种资源一次只允许一个进程访问,即该资源一旦分配给某个进程,其他进程就不能再访问,直到该进程访问结束。
  2. 占有且等待:一个进程本身占有资源
  3. 不可抢占:别人已经占有了某项资源,你不能因为自己也需要该资源,就去把别人的资源抢过来。
  4. 循环等待:存在一个进程链,使得每个进程都占有下一个进程所需的至少一种资源。

当以上四个条件均满足,必然会造成死锁,发生死锁的进程无法进行下去,它们所持有的资源也无法释放。这样会导致CPU的吞吐量下降。所以死锁情况是会浪费系统资源和影响计算机的使用性能的。

预防死锁

  1. 资源一次性分配:(破坏请求和保持条件)
  2. 可剥夺资源:即当某进程新的资源未满足时,释放已占有的资源(破坏不可剥夺条件)
  3. 资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反(破坏环路等待条件)

避免死锁

预防死锁的几种策略,会严重地损害系统性能。因此在避免死锁时,要施加较弱的限制,从而获得较满意的系统性能。由于在避免死锁的策略中,允许进程动态地申请资源。因而,系统在进行资源分配之前预先计算资源分配的安全性。若此次分配不会导致系统进入不安全状态,则将资源分配给进程;否则,进程等待。其中最具有代表性的避免死锁算法是银行家算法。

内存

操作系统中的缺页中断

malloc()和mmap()等内存分配函数,在分配时只是建立了进程虚拟地址空间,并没有分配虚拟内存对应的物理内存。当进程访问这些没有建立映射关系的虚拟内存时,处理器自动触发一个缺页异常。
缺页中断: 在请求分页系统中,可以通过查询页表中的状态位来确定所要访问的页面是否存在于内存中。每当所要访问的页面不在内存时,会产生一次缺页中断,此时操作系统会根据页表中的外存地址在外存中找到所缺的一页,将其调入内存。

缺页本身是一种中断,与一般的中断一样,需要经过4个处理步骤:

  1. 保护CPU现场
  2. 分析中断原因
  3. 转入缺页中断处理程序进行处理
  4. 恢复CPU现场,继续执行
    但是缺页中断是由于所要访问的页面不存在于内存时,由硬件所产生的一种特殊的中断,因此,与一般的中断存在区别:
  5. 在指令执行期间产生和处理缺页中断信号
  6. 一条指令在执行期间,可能产生多次缺页中断
  7. 缺页中断返回时,执行产生中断的一条指令,而一般的中断返回时,执行下一条指令。

系统调用和库函数

系统调用是通向操作系统本身的接口,是面向底层硬件的。通过系统调用,可以使得用户态运行的进程与硬件设备(如CPU、磁盘、打印机等)进行交互,是操作系统留给应用程序的一个接口。下面为适用于访问设备驱动程序的系统调用:

open: 打开文件或设备 
read: 从打开的文件或设备中读取数据
write: 向打开的文件或设备中写入数据
close: 关闭文件或设备
ioctl: 把控制信息传递给设备驱动文件

库函数(Library function)是把函数放到库里,供别人使用的一种方式。.方法是把一些常用到的函数编完放到一个文件里,供不同的人进行调用。一般放在.lib文件中。库函数调用则是面向应用开发的,库函数可分为两类,一类是C语言标准规定的库函数,一类是编译器特定的库函数。
系统调用是为了方便使用操作系统的接口,而库函数则是为了编程的方便。库函数调用与系统无关,不同的系统,调用库函数,库函数会调用不同的底层函数实现,因此可移植性好。由于库函数是基于c库的,因此不能用于内核对于底层驱动设备的操作。

区别

对比维度 系统调用(System Call) 库函数(Library Function)
本质 操作系统内核提供的接口 标准库或第三方库提供的函数接口
提供者 操作系统内核 C/C++ 标准库、运行时库或第三方库
执行位置 内核态 通常在用户态
是否发生用户态/内核态切换 会发生(陷入内核) 纯库函数通常不发生;若内部调用系统调用则会发生
性能开销 相对较高(涉及模式切换与安全检查) 相对较低(纯用户态时)
功能侧重点 进程、文件、设备、内存、网络等底层资源控制 字符串处理、格式化 I/O、算法、容器、工具能力等
可移植性 较弱(依赖具体操作系统) 较强(尤其标准库接口)
错误处理 常见为返回 -1 并设置 errno 按库约定返回,部分也会设置 errno
与对方关系 底层能力提供者 常对系统调用进行封装,提升易用性
典型示例 open、read、write、close、ioctl、fork printf、fopen、fread、malloc、qsort、std::sort
适用场景 底层开发、需要精细控制系统资源 应用开发、追求开发效率与跨平台

上下文

一个进程的上下文可以分为三个部分:用户级上下文、寄存器上下文以及系统级上下文。

  1. 用户级上下文: 正文、数据、用户堆栈以及共享存储区;
  2. 寄存器上下文: 通用寄存器、程序寄存器(IP)、处理器状态寄存器(EFLAGS)、栈指针(ESP);
  3. 系统级上下文: 进程控制块task_struct、内存管理信息(mm_struct、vm_area_struct、pgd、pte)、内核栈。

当发生进程调度时,进行进程切换就是上下文切换(context switch).操作系统必须对上面提到的全部信息进行切换,新调度的进程才能运行。而系统调用进行的模式切换(mode switch)。模式切换与进程切换比较起来,容易很多,而且节省时间,因为模式切换最主要的任务只是切换进程寄存器上下文的切换。硬件通过触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理。所谓的“ 中断上下文”,其实也可以看作就是硬件传递过来的这些参数和内核需要保存的一些其他环境(主要是当前被打断执行的进程环境)。中断时,内核不代表任何进程运行,它一般只访问系统空间,而不会访问进程空间,内核在中断上下文中执行时一般不会阻塞。

线程切换上下文

线程在切换的过程中需要保存当前线程id、线程状态、堆栈、寄存器状态等信息。其中寄存器主要包括SP、PC、EAX等寄存器,其主要功能如下:

  • SP:堆栈指针,指向当前栈的栈顶地址
  • PC:程序计数器,存储下一条将要执行的指令
  • EAX:累加寄存器,用于加法乘法的缺省寄存器