22 | 热点问题答疑(2):内核如何阻塞与唤醒进程?

在专栏的第三个模块,我们学习了Tomcat连接器组件的设计,其中最重要的是各种I/O模型及其实现。而I/O模型跟操作系统密切相关,要彻底理解这些原理,我们首先需要弄清楚什么是进程和线程,什么是虚拟内存和物理内存,什么是用户空间和内核空间,线程的阻塞到底意味着什么,内核又是如何唤醒用户线程的等等这些问题。可以说掌握这些底层的知识,对于你学习Tomcat和Jetty的原理,乃至其他各种后端架构都至关重要,这些知识可以说是后端开发的“基石”。

在专栏的留言中我也发现很多同学反馈对这些底层的概念很模糊,那今天作为模块的答疑篇,我就来跟你聊聊这些问题。

进程和线程

我们先从Linux的进程谈起,操作系统要运行一个可执行程序,首先要将程序文件加载到内存,然后CPU去读取和执行程序指令,而一个进程就是“一次程序的运行过程”,内核会给每一个进程创建一个名为task_struct的数据结构,而内核也是一段程序,系统启动时就被加载到内存中了。

进程在运行过程中要访问内存,而物理内存是有限的,比如16GB,那怎么把有限的内存分给不同的进程使用呢?跟CPU的分时共享一样,内存也是共享的,Linux给每个进程虚拟出一块很大的地址空间,比如32位机器上进程的虚拟内存地址空间是4GB,从0x00000000到0xFFFFFFFF。但这4GB并不是真实的物理内存,而是进程访问到了某个虚拟地址,如果这个地址还没有对应的物理内存页,就会产生缺页中断,分配物理内存,MMU(内存管理单元)会将虚拟地址与物理内存页的映射关系保存在页表中,再次访问这个虚拟地址,就能找到相应的物理内存页。每个进程的这4GB虚拟地址空间分布如下图所示:

进程的虚拟地址空间总体分为用户空间和内核空间,低地址上的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的寄存器中,然后执行进程上下文指定的下一条指令。

而阻塞的本质就是将进程的task_struct移出运行队列,添加到等待队列,并且将进程的状态的置为TASK_UNINTERRUPTIBLE或者TASK_INTERRUPTIBLE,重新触发一次CPU调度让出CPU。

那线程怎么唤醒呢?线程在加入到等待队列的同时向内核注册了一个回调函数,告诉内核我在等待这个Socket上的数据,如果数据到了就唤醒我。这样当网卡接收到数据时,产生硬件中断,内核再通过调用回调函数唤醒进程。唤醒的过程就是将进程的task_struct从等待队列移到运行队列,并且将task_struct的状态置为TASK_RUNNING,这样进程就有机会重新获得CPU时间片。

这个过程中,内核还会将数据从内核空间拷贝到用户空间的堆上。

当read系统调用返回时,CPU又从内核态切换到用户态,继续执行read调用的下一行代码,并且能从用户空间上的Buffer读到数据了。

小结

今天我们谈到了一次Socket read系统调用的过程:首先CPU在用户态执行应用程序的代码,访问进程虚拟地址空间的用户空间;read系统调用时CPU从用户态切换到内核态,执行内核代码,内核检测到Socket上的数据未就绪时,将进程的task_struct结构体从运行队列中移到等待队列,并触发一次CPU调度,这时进程会让出CPU;当网卡数据到达时,内核将数据从内核空间拷贝到用户空间的Buffer,接着将进程的task_struct结构体重新移到运行队列,这样进程就有机会重新获得CPU时间片,系统调用返回,CPU又从内核态切换到用户态,访问用户空间的数据。

不知道今天的内容你消化得如何?如果还有疑问,请大胆的在留言区提问,与我和其他同学一起讨论。如果你觉得今天有所收获,欢迎你把它分享给你的朋友。

精选留言

  • 帽子丨影

    2019-09-24 15:31:15

    老师好,既然用户态运行时也会占用cpu,内核态又可以访问整个虚拟空间,为什么不让cpu一直处在内核态呢,这样就没有切换带来损耗了
    作者回复

    如果是这样的话,你写的应用程序直接在内核态运行,权限级别太高,出了问题会导致整个操作系统崩溃,所有才有了用户态核心态,算是一种隔离和容错吧。

    2019-10-01 09:42:31

  • 一塌糊涂

    2019-07-11 11:39:16

    老师问个问题,用户态切换到内核态,使用的是虚拟空间内核地址?这时用户线程会挂起执行内核线程吗?这是两个线程吗?
    作者回复

    cpu处于内核态时使用的是内核地址空间,用户线程的挂起其实是由内核完成的,具体来说就是系统调用发触发软中断,cpu在内核态模式下执行软中断程序,也就是系统调用的具体实现函数(内核代码),内核代码执行过程中发现网络数据未就绪就主动让出cpu。这个时候才会将当前线程阻塞,因此从这个角度看,不是两个线程,而是一个线程在不同cpu模式下的执行过程。

    2019-07-11 23:45:52

  • Geek_0quh3e

    2019-07-01 11:34:19

    虚拟内存是一个联系的地址空间,该地址空间由不一定连续的物理内存组成。 这样理解对吗?
    作者回复

    对的

    2019-07-01 21:35:31

  • Liam

    2019-06-29 09:04:28

    如果是通过mmap读数据,流程是怎样的呢?

    1 如果没有数据,是否会阻塞?
    2 不需要拷贝数据?意思是用户进程可以直接读mmap,不需要拷贝到堆吗?
    作者回复

    mmap不支持Socket读写,只支持磁盘文件。

    通过mmap将文件映射到内存后,直接写读写内存,内核会负责将数据刷新到磁盘文件。

    2019-06-29 23:53:37

  • 东方奇骥

    2019-06-30 14:14:22

    答疑干货还挺多的,操作系统一直是自己的弱项,最近也在学习,然后发现极客时间专栏里牛逼的老师操作系统基础都很好。
  • nightmare

    2019-06-30 00:33:08

    老师今天讲了线程和进程,进程和线程都是统一在内核空间建立task_truct,根据代码是否有系统调用在用户态和内核态来做上下文切换,然后还讲了read的系统调用过程以及进程的虚拟内存和物理内存的机制,有一点没明白,是每个进程都会有一个虚拟内核空间吗?然后进程的虚拟内核空间映射到系统管理的内核空间上?
    作者回复

    每一个进程的进程空间都包含内核空间,但是内核是各进程共享的,因此可以这里理解,内核代码运行过程中访问的内存空间被映射到各个进程空间的高地址3-4G。

    2019-07-03 14:25:44

  • 妥协

    2019-07-01 07:57:22

    之前有讲到过,jvm用到的内存空间,在本文中介绍的进程的地址空间划分中,是属于那一部分?
    作者回复

    这样理解,JVM启动时在用户空间上分配了一大块连续内存空间作为JVM内存区域,具体在进程空间的数据区和栈区的中间。

    2019-07-03 14:33:30

  • 长脖子树

    2019-08-16 00:59:32

    看了作者的文章又去看了 copy-on-write 和 MappedByteBuffer 了解又加深了一层 哈哈
    作者回复

    2019-08-19 21:35:39

  • 飞翔

    2019-06-29 12:13:25

    用户态和用户空间是啥关系?
    作者回复

    你可以理解为CPU上有个开关,可以设置CPU的工作模式:用户态和内核态。在用户态模式下访问用户空间,也就是低地址的3GB。

    2019-06-29 23:49:34

  • 迎风劲草

    2019-07-01 23:22:20

    老师,task_struct 具体是什么结构,都存储了什么?
    作者回复

    task_struct存储了一个进程的所有信息,比如进程id,执行了什么程序,工作目录,打开了什么文件...

    2019-07-06 22:47:52

  • brianway

    2019-07-05 00:18:39

    “线程有自己的task_struct结构体和运行栈区,但是线程的其他资源都是跟父进程共用”,这句话怎么理解。线程的task_struct结构体和运行栈区在图中哪个部分?

    是从进程的内核空间里面分配出来的,还是用户空间里分配出来的?具体哪个部分的内存?
    作者回复

    每个线程都有tast_struct,由内核创建,在内核空间上。

    2019-07-06 22:36:06

  • 梁中华

    2019-07-10 18:14:50

    文中混合使用“可运行队列”和“运行队列”,这两者应该是同一个队列吧。另外关于线程唤醒部分我有一个理解,老师看看对否:当前线程因IO等待而进入"阻塞状态(Blocked)",同时进入线程进入等待队列。让IO操作ready后,内核唤醒等待中的线程,线程状态变成"可运行状态(Runnable)",同时该线程进入可运行队列,等待CPU调度后进入运行状态(Running)。
    作者回复

    对的

    2019-07-11 23:58:06

  • 靠人品去赢

    2019-10-16 16:16:34

    进程和线程:进程的本质就是“一个程序运行的过程”,而线程可以看做是一个小进程因为他也有进程的task_struct这些东西,但是他是跟父进程公用资源的。
    阻塞的本质:就是移出运行队列到等待队列,条件好了内核通知把数据写进去用户态,就又进入到运行队列等待CPU时间片。
    老师这本质也归纳的太好了吧。
  • -W.LI-

    2019-06-29 18:13:27

    感谢老师,万分感谢。上次有个问题我不明白,老师还帮我查阅源码确认了。李老师,还有http那个老师是最最负责的真的万分感谢。
    向老师,我要把计算机组成原理和操作系统自己看一遍看不懂就看两遍。
    作者回复

    😁

    2019-06-29 23:47:19

  • xiaolin777

    2019-08-13 06:50:04

    老师,您说的task_struct是指进程控制块吗?(刚才还没打完就点了保存,以为是保存草稿,没想到直接提交了,后台能删除吗)
    作者回复

    对的,进程控制块是个比较抽象的名字,具体实现就是内核的一个数据结构task_struct

    2019-08-19 21:45:32

  • 学无涯

    2019-08-12 19:27:11

    1、文中开始说:“task_struct中保存了打开的文件、创建的socket以及CPU运行的上下文"。后面又说"线程有自己的task_struct“。那最后说的“和父进程共用的资源:创建的socket。打开的文件等”这些是怎么共享呀,socket和打开的文件都是在task_struct中保存的,线程有自己独立的task_struct,还需要共享吗?
    2、task_struct为什么不用数组呀,数组不是更节省空间吗,而且随机访问这个特性也支持CPU随机调度(猜的,从多线程的不确定性感觉应该是随机调度),是考虑到这个数组有可能很大和频繁扩容的问题才采用双向链表吗?
    以上问题,请老师指点一下!
    作者回复

    1.task_struct上保存的是指针,本质是一个内存地址,比如打开的文件是这样的:
    struct files_struct *files;

    一个进程内的所有线程的task_struct的files变量都指向同一个内存地址,也就是同一个files_struct结构体

    2,线程需要动态的创建和销毁,意味task_struct链表需要频繁的添加和删除节点,所以用链表

    2019-08-19 22:54:20

  • 蚂蚁内推+v

    2019-07-06 17:36:50

    唤醒时,内核调用的回调函数是指硬件中断程序么
    作者回复

    不是的,硬件终端程序不能被阻塞,需要迅速处理完毕,因此这个硬件中断程序会触发一个软中断,在软中断处理程序中才会调用回调函数。

    2019-07-06 21:46:58

  • 妥协

    2019-07-04 22:37:59

    您是不是指的数据区和堆区?数据区和栈区不是连续的呀
    作者回复

    是数据区和栈区的中间,这个中间的部分有本地堆区,JVM内存区,mmap映射都在这里

    2019-07-06 22:33:42

  • 一道阳光

    2019-06-29 08:07:49

    老师,每节课后的思考题什么时侯也答疑下
    作者回复

    嗯,基本上每篇的留言回复里都能找到答案

    2019-06-29 23:55:10

  • Ericens

    2019-10-09 17:06:16

    李老师,请教个关于协程与线程的疑问。比如,a协程调用socket. read(),此时数据没有准备好,则继续调度b协程。

    把协程换成线程,上面这个过程哪个更加轻量?协程还是线程?
    我理解这个过程涉及的过程如下,都一样。
    1.都有系统调用read(),从用户态切换到了内核态,
    2.都有上下文切换。(不同协程的寄存器,和不同线程的寄存器)
    3. 都要执行任务调度。协程调度或者线程调度。

    那协程到底轻量在哪?