You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

55 lines
6.9 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 22 | 热点问题答疑2内核如何阻塞与唤醒进程
在专栏的第三个模块我们学习了Tomcat连接器组件的设计**其中最重要的是各种I/O模型及其实现**。而I/O模型跟操作系统密切相关要彻底理解这些原理我们首先需要弄清楚什么是进程和线程什么是虚拟内存和物理内存什么是用户空间和内核空间线程的阻塞到底意味着什么内核又是如何唤醒用户线程的等等这些问题。可以说掌握这些底层的知识对于你学习Tomcat和Jetty的原理乃至其他各种后端架构都至关重要这些知识可以说是后端开发的“基石”。
在专栏的留言中我也发现很多同学反馈对这些底层的概念很模糊,那今天作为模块的答疑篇,我就来跟你聊聊这些问题。
## 进程和线程
我们先从Linux的进程谈起操作系统要运行一个可执行程序首先要将程序文件加载到内存然后CPU去读取和执行程序指令而一个进程就是“一次程序的运行过程”内核会给每一个进程创建一个名为`task_struct`的数据结构,而内核也是一段程序,系统启动时就被加载到内存中了。
进程在运行过程中要访问内存而物理内存是有限的比如16GB那怎么把有限的内存分给不同的进程使用呢跟CPU的分时共享一样内存也是共享的Linux给每个进程虚拟出一块很大的地址空间比如32位机器上进程的虚拟内存地址空间是4GB从0x00000000到0xFFFFFFFF。但这4GB并不是真实的物理内存而是进程访问到了某个虚拟地址如果这个地址还没有对应的物理内存页就会产生缺页中断分配物理内存MMU内存管理单元会将虚拟地址与物理内存页的映射关系保存在页表中再次访问这个虚拟地址就能找到相应的物理内存页。每个进程的这4GB虚拟地址空间分布如下图所示
![](https://static001.geekbang.org/resource/image/d7/86/d78cd0faf850c4efdbe00c63659e0f86.png)
进程的虚拟地址空间总体分为用户空间和内核空间低地址上的3GB属于用户空间高地址的1GB是内核空间这是基于安全上的考虑用户程序只能访问用户空间内核程序可以访问整个进程空间并且只有内核可以直接访问各种硬件资源比如磁盘和网卡。那用户程序需要访问这些硬件资源该怎么办呢答案是通过系统调用系统调用可以理解为内核实现的函数比如应用程序要通过网卡接收数据会调用Socket的read函数
```
ssize_t read(int fd,void *buf,size_t nbyte)
```
CPU在执行系统调用的过程中会从用户态切换到内核态CPU在用户态下执行用户程序使用的是用户空间的栈访问用户空间的内存当CPU切换到内核态后执行内核代码使用的是内核空间上的栈。
从上面这张图我们看到用户空间从低到高依次是代码区、数据区、堆、共享库与mmap内存映射区、栈、环境变量。其中堆向高地址增长栈向低地址增长。
请注意用户空间上还有一个共享库和mmap映射区Linux提供了内存映射函数mmap 它可将文件内容映射到这个内存区域用户通过读写这段内存从而实现对文件的读取和修改无需通过read/write系统调用来读写文件省去了用户空间和内核空间之间的数据拷贝Java的MappedByteBuffer就是通过它来实现的用户程序用到的系统共享库也是通过mmap映射到了这个区域。
我在开始提到的`task_struct`结构体本身是分配在内核空间,它的`vm_struct`成员变量保存了各内存区域的起始和终止地址,此外`task_struct`中还保存了进程的其他信息比如进程号、打开的文件、创建的Socket以及CPU运行上下文等。
在Linux中线程是一个轻量级的进程轻量级说的是线程只是一个CPU调度单元因此线程有自己的`task_struct`结构体和运行栈区但是线程的其他资源都是跟父进程共用的比如虚拟地址空间、打开的文件和Socket等。
## 阻塞与唤醒
我们知道当用户线程发起一个阻塞式的read调用数据未就绪时线程就会阻塞那阻塞具体是如何实现的呢
Linux内核将线程当作一个进程进行CPU调度内核维护了一个可运行的进程队列所有处于`TASK_RUNNING`状态的进程都会被放入运行队列中,本质是用双向链表将`task_struct`链接起来排队使用CPU时间片时间片用完重新调度CPU。所谓调度就是在可运行进程列表中选择一个进程再从CPU列表中选择一个可用的CPU将进程的上下文恢复到这个CPU的寄存器中然后执行进程上下文指定的下一条指令。
![](https://static001.geekbang.org/resource/image/b6/e8/b6794ae547bccdf71c0f6ea4e93012e8.png)
而阻塞的本质就是将进程的`task_struct`移出运行队列,添加到等待队列,并且将进程的状态的置为`TASK_UNINTERRUPTIBLE`或者`TASK_INTERRUPTIBLE`重新触发一次CPU调度让出CPU。
那线程怎么唤醒呢线程在加入到等待队列的同时向内核注册了一个回调函数告诉内核我在等待这个Socket上的数据如果数据到了就唤醒我。这样当网卡接收到数据时产生硬件中断内核再通过调用回调函数唤醒进程。唤醒的过程就是将进程的`task_struct`从等待队列移到运行队列,并且将`task_struct`的状态置为`TASK_RUNNING`这样进程就有机会重新获得CPU时间片。
这个过程中,内核还会将数据从内核空间拷贝到用户空间的堆上。
![](https://static001.geekbang.org/resource/image/2e/b8/2e27945eee139201de846e6a58c031b8.png)
当read系统调用返回时CPU又从内核态切换到用户态继续执行read调用的下一行代码并且能从用户空间上的Buffer读到数据了。
## 小结
今天我们谈到了一次Socket read系统调用的过程首先CPU在用户态执行应用程序的代码访问进程虚拟地址空间的用户空间read系统调用时CPU从用户态切换到内核态执行内核代码内核检测到Socket上的数据未就绪时将进程的`task_struct`结构体从运行队列中移到等待队列并触发一次CPU调度这时进程会让出CPU当网卡数据到达时内核将数据从内核空间拷贝到用户空间的Buffer接着将进程的`task_struct`结构体重新移到运行队列这样进程就有机会重新获得CPU时间片系统调用返回CPU又从内核态切换到用户态访问用户空间的数据。
不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。