大家好,我是小志。我们的口号是:学习、成长、应对未来风险。 首先前缀知识,我们需要了解用户空间和内核空间。对于32位操作系统而言,它的寻址空间(虚拟存储空间)为4GB (也就是2的32次方)。
为什么要有“内核空间”和“用户空间”?
在 Linux 中,CPU 有两种运行级别(Ring):
为什么要这样划分?
32 位系统的“4G 地址空间”详解
32 位系统的寻址能力是 232=4,294,967,296232=4,294,967,296 字节,也就是 4GB。
Linux 将这 4GB 的虚拟地址空间,一刀切地分成了两半:
1. 用户空间 (User Space)
2. 内核空间 (Kernel Space)
同步与异步( Synchronous and Asynchronous)
同步就是一个任务的完成需要依赖另外一个任务时,只有等待被依赖的任务完成后,依赖的任务才能算完成,这是一种可靠的任务序列。要么成功都成功,失败都失败,两个任务的状态可以保持一致。
异步是不需要等待被依赖的任务完成,只是通知被依赖的任务要完成什么工作,依赖的任务也立即执行,只要自己完成了整个任务就算完成了。至于被依赖的任务最终是否真正完成,依赖它的任务无法确定,所以它是不可靠的任务序列。
核心区别在于:当数据从内核拷贝到用户空间时,是谁在干活?这不仅仅是“卡不卡”的问题,而是“谁负责搬运数据”的问题。
以数据拷贝为界,在linux中,一次I/O操作(如读取文件)分为两个阶段:
等待数据就绪:数据从磁盘/网卡进入内核缓冲区。
数据拷贝:数据从内核缓冲区拷贝到用户缓冲区。
同步与异步的分水岭就在于第 2 阶段。
同步I/O定义:当数据准备好后,用户进程必须自己动手(调用 read 等系统调用)将数据从内核拷贝到用户空间。在拷贝完成之前,进程是被阻塞的。
异步I/O定义:用户进程发起请求后,内核负责把数据从内核缓冲区拷贝到用户缓冲区。当一切完成后,内核通知进程“数据已经在你的缓冲区里了,拿去用吧”。
// 1. 等待事件 (内核告诉你:fd 5 有数据了)int n = epoll_wait(epfd, events, MAX_EVENTS, -1);if (events[0].events & EPOLLIN) { // 2. 同步点:用户空间必须自己动手调用 read 来搬运数据。 // 在这行代码执行期间,线程是阻塞的 ssize_t count = read(events[0].data.fd, buf, sizeof(buf)); handle_data(buf);}
struct iocb io;io.u.c.buf = buf; // 指定你的用户缓冲区io.u.c.offset = 0;io.u.c.nbytes = 1024;// 1. 提交请求 (告诉内核:帮我把数据读到 buf 里)io_submit(ioctx, 1, &io);// 2. 关键点:这里没有 read() 调用!// 你可以直接去处理其他逻辑,CPU 完全解放// ... 做其他事情 ...// 3. 等待完成通知 (或者轮询状态)// 当这里返回时,数据已经安安静静地躺在 buf 里了io_getevents(ioctx, 1, 1, &evt, NULL);// 直接使用 buf,无需拷贝handle_data(buf);
堵塞与非堵塞( Blocking and Non-blocking)
阻塞调用是指调用结果返回之前,当前线程会被挂起,一直处于等待消息通知,不能够执行其他业务。函数只有在得到结果之后才会返回。
非阻塞和阻塞的概念相对应,指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。虽然表面上看非阻塞的方式可以明显的提高CPU的利用率,但是也带了另外一种后果就是系统的线程切换增加。增加的CPU执行时间能不能补偿系统的切换成本需要好好评估。
在 Linux 系统编程中,阻塞(Blocking)与非阻塞(Non-blocking)描述的是进程(或线程)在发起 I/O 操作时,如果数据没准备好,它会怎么做。
简单来说,这是关于“等待时的姿态”。
假设你(用户进程)想要从网卡读取一个数据包。
阻塞 I/O
定义:当你调用 read() 时,如果内核缓冲区里没有数据,内核会把你挂起(Sleep)。
状态:你的进程进入“休眠状态”,让出 CPU 给其他进程。
醒来:直到数据到达内核缓冲区,内核把你唤醒,并把数据拷贝给你,read() 才会返回。
比喻:死等。你在河边钓鱼,鱼不上钩你就一直盯着鱼竿,啥也不干,直到鱼上钩。
非阻塞 I/O
定义:当你调用 read() 时,如果内核缓冲区里没有数据,内核不会挂起你,而是立刻返回一个错误码(通常是 EAGAIN 或 EWOULDBLOCK)。
在实际的编码中,阻塞与非阻塞的区别通常只在于打开文件(或 Socket)时的一个标志位。
1. 阻塞I/O (默认阻塞):
// 默认打开文件/Socket,就是阻塞的int fd = open("data.txt", O_RDONLY); char buf[1024];printf("等待读取数据...\n");// 如果数据没来,程序就卡在这里,直到数据读完ssize_t n = read(fd, buf, sizeof(buf)); printf("读取完成,读取了 %zd 字节\n", n);
2. 非阻塞 I/O (需要设置标志位)
int fd = open("data.txt", O_RDONLY);// 关键步骤:设置非阻塞标志int flags = fcntl(fd, F_GETFL, 0);fcntl(fd, F_SETFL, flags | O_NONBLOCK);char buf[1024];while (1) { ssize_t n = read(fd, buf, sizeof(buf)); if (n > 0) { printf("读取成功!\n"); break; } else if (n == -1) { // 【核心逻辑】如果没有数据,不会卡住,而是返回 EAGAIN if (errno == EAGAIN || errno == EWOULDBLOCK) { printf("数据没准备好,我去干点别的事...\n"); sleep(1); // 稍微睡一下,避免 CPU 跑满 } else { perror("read error"); break; } }}
| 特性 | 阻塞 I/O | 非阻塞 I/O |
|---|
| CPU 占用 | 低。等待期间进程休眠,不占 CPU。 | 高。如果单纯轮询,会疯狂空转消耗 CPU(忙轮询)。 |
| 编程模型 | 简单。顺序执行,逻辑清晰。 | 复杂。需要处理 EAGAIN,通常需要状态机或配合 epoll。 |
| 响应速度 | 慢。如果前面有慢请求阻塞,后面的请求得排队。 | 快。不会因为一个请求卡死整个线程。 |
| 上下文切换 | 多。每次阻塞和唤醒都涉及用户态/内核态切换。 | 少(如果配合 epoll)。大部分时间在用户态处理。 |
(1)如果这个线程在等待当前函数返回时,仍在执行其他消息处理,那这种情况就叫做同步非阻塞;(通俗的讲,同步非阻塞:我不等你(非阻塞),但是我会盯着你(同步轮训/检查)。)
(2) 如果这个线程在等待当前函数返回时,没有执行其他消息处理,而是处于挂起等待状态,那这种情况就叫做同步阻塞;
同步/异步关注的是消息通知的机制,而阻塞/非阻塞关注的是程序(线程)等待消息通知时的状态。
进程切换(上下文切换) Process Switching - Context Switching
为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。
它让单个 CPU 核心通过极快地“接力跑”,创造出多个程序同时运行的假象。
触发条件
进程切换并非随机发生,而是由内核调度器在特定事件触发下执行的。主要有以下几种情况:1、时间片耗尽:这是最常见的情况。每个进程被分配一个固定的 CPU 执行时间(时间片,例如 10ms)。时间一到,内核就会触发时钟中断,强制进行切换。2、主动放弃 CPU:进程因等待某个事件而主动让出 CPU,例如发起 I/O 请求(读写文件、网络通信)、申请锁失败、或调用 sleep() 函数。此时进程会进入阻塞状态。3、被更高优先级进程抢占:当一个更高优先级的进程变为“可运行”状态时(例如,它等待的 I/O 完成了),调度器可能会立即暂停当前进程,让高优先级进程先执行。4、硬件中断:外部硬件设备(如网卡收到数据包、键盘被按下)发出中断信号,CPU 必须暂停当前任务,转而去执行中断处理程序。
切换是如何完成的?
进程切换的本质是 CPU 上下文(Context)的保存与恢复。你可以把它想象成一场特工交接任务:
1. 保存旧进程的“现场”
当决定要切换时,内核首先要保存当前进程的所有执行状态,也就是它的“上下文”。这些数据主要存储在 CPU 的寄存器中,包括:
内核会将这些寄存器数据保存到当前进程的进程控制块(PCB),也就是内核中的 task_struct 结构体里。
2. 切换地址空间(如果需要)
如果下一个要运行的进程拥有独立的虚拟地址空间(绝大多数用户进程都是),内核必须切换内存视图。
核心动作:将新进程的页表基地址加载到 CPU 的 CR3 寄存器中。
后果:CR3 一变,CPU 的内存映射关系就完全改变了。当前进程再也无法访问前一个进程的任何内存数据,实现了严格的隔离。
副作用:切换页表会导致 TLB(Translation Lookaside Buffer,转换后备缓冲区) 被刷新,因为旧的地址映射缓存全部失效了。
3. 恢复新进程的“现场”
最后,内核从新进程的 task_struct 中取出之前保存的寄存器值,加载到 CPU 的寄存器中。当一切恢复就绪,CPU 会根据新程序计数器(PC)的地址,开始执行新进程的代码,就像它从未被中断过一样。
这个过程在 Linux 内核源码中主要由 context_switch() 函数实现,其中包含了 switch_mm()(切换内存)和 switch_to()(切换寄存器)等关键步骤。
切换的代价有多大?
进程切换虽然高效,但绝非免费。频繁的切换会显著影响系统性能,其开销主要来自两个方面:
直接开销:
间接开销(缓存污染):
如何优化?
当上下文切换率过高并导致 CPU 使用率低下时(即 CPU 大量时间花在切换上而非执行任务),就需要进行优化:
减少不必要的进程/线程:过多的线程会迫使内核频繁切换。优化应用,使用线程池,避免创建海量短生命周期的线程。
使用 CPU 亲和性(CPU Affinity):通过 taskset 命令将特定的关键进程“绑定”到指定的 CPU 核心上。这样可以最大程度地利用 CPU 缓存,减少缓存失效。
优化锁竞争:应用程序中过度的锁竞争会导致线程频繁阻塞和唤醒,从而触发大量切换。优化锁的粒度或使用无锁数据结构可以缓解此问题。
用线程代替进程:同一进程内的线程共享地址空间,因此线程切换不需要切换页表和刷新 TLB,开销远小于进程切换。
进程阻塞
正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的。
简单来说,当进程继续执行的条件不满足时(比如数据没来、锁被占),它主动放弃 CPU,进入“睡眠”状态,等待被唤醒的过程。底层流程如下:
进程阻塞不是“卡死”,而是一种受控的暂停。其底层流程如下:
发起请求:进程发起一个系统调用(如 read() 读取文件,或 lock() 申请锁)。
条件不满足:内核检查资源,发现数据还没到,或者锁被别人拿着。
状态变更:内核将进程的状态从 运行态(Running) 修改为 阻塞态(Blocked/Sleeping)。
调度切换:调度器介入,把这个进程从 CPU 上拿下来,把 CPU 分配给其他就绪的进程(上下文切换)。
等待队列:该进程被放入一个特定的等待队列(如“磁盘 I/O 等待队列”或“锁等待队列”)。
唤醒:当资源就绪(如网卡收到数据、锁被释放),内核中断或检查时发现条件满足,将该进程状态改回 就绪态(Runnable),等待下一次被调度。
Linux 中的两种“睡眠”状态
在 Linux 中,用 ps 或 top 命令查看进程状态时,你会发现阻塞状态其实细分为两种:可中断睡眠(S) 和 不可中断睡眠(D)。这是很多技术文章的盲区。
可中断睡眠 (Interruptible Sleep) - 状态码S
不可中断睡眠 (Uninterruptible Sleep) - 状态码D
含义:进程正在等待关键资源(通常是硬件 I/O),此时连信号都打不醒它。
为什么这么设计?:为了保证数据一致性。如果进程正在等待磁盘写入,此时突然被杀掉,磁盘上的数据可能只写了一半,导致文件系统损坏。所以内核强制它必须等硬件操作彻底完成。
风险:如果硬件坏了(如 NFS 服务器宕机、磁盘坏道),进程会永远卡在 D 状态,kill -9 也杀不掉,只能重启系统。
文件描述符
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。
内核视角:三层数据结构
进程控制块 (task_struct):这是进程的“身份证”。每个进程都有一个 task_struct,其中包含一个指向文件表的指针。
文件表 (files_struct):
这是进程的“私有账本”。它的核心成员是一个指针数组 fd_array
文件对象 (struct file):
这是真正的“文件说明书。”fd_array的每个元素都指向一个 file结构体。
重定向与复制
重定向的本质:Shell 中的 > 或 < 操作,本质上是修改 fd_array 中某个下标指向的目标。
文件描述符的复制(dup / dup2):
dup(old_fd):复制一个 fd,返回新的最小可用下标。
dup2(old_fd, new_fd):强制将 new_fd 指向 old_fd 指向的文件对象。
关键点:复制后的两个 fd 共享同一个文件对象(struct file),这意味着它们共享读写偏移量。一个读走了 10 个字节,另一个接着读就是从第 11 个字节开始。
缓存
缓存 IO ,大多数文件系统的默认 IO 操作都是缓存 IO。在 Linux 的缓存 IO 机制中,操作系统会将 IO 的数据缓存在文件系统的页缓存(page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。
缓存 IO 的缺点:
数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。