写点什么

精通高并发与内核 | Linux 内核协程解析

  • 2022 年 9 月 20 日
    上海
  • 本文字数:3112 字

    阅读完需:约 10 分钟

精通高并发与内核 | Linux内核协程解析

前言

📫作者简介小明java问道之路,专注于研究计算机底层/Java/Liunx 内核,就职于大型金融公司后端高级工程师,擅长交易领域的高安全/可用/并发/性能的架构设计📫 

🏆 InfoQ 签约博主、CSDN 专家博主/Java 领域优质创作者/CSDN 内容合伙人、阿里云专家/签约博主、华为云专家、51CTO 专家/TOP 红人 🏆

🔥如果此文还不错的话,还请👍关注、点赞、收藏三连支持👍一下博主~


本文导读

Linux 中如何进行进程的切换。由于线程的表现形式也为进程,只不过共享了数据,所以我们研究进程即可,线程也是如此,同时协程是为了方便更好的编程,而不是提高性能

一、协程的出现

在上面我们看到了进程的切换,可以看到从一个进程切换到另一个进程,如果不研究底层,我们根本无法知道是如此的复杂,虽然线程共享了内存数据,但本质上还是进程,所以切换也相对耗时。

那么,有没有一种不需 要切换却能拥有类似于线程的东西呢?答案便是协程

在网上充斥着大量:协程性能高于线程的话题,笔者这里以此文详细介绍协程的作用与实现方式,

帮助读者理清思路,树立正确的理解:协程是为了方便更好的编程,而不是提高性能。我们来看看该代码有何性能问题:

1、p 线程仅仅生产了一个数据,然后就阻塞了,等待 c 线程获取数据

2、c 线程仅仅获取了一个数据,然后就阻塞了,等待 p 线程生产数据

3、通过前面的原理分析,我们知道一个线程在底层需 要将自己加入到阻塞队列,然后调用 schedule 函数将 CPU 控制权移交。其中又包含了系统调用(进入内核完成阻塞)

4、线程也需要自己的线程栈和 TCB (线程控制块)将会占用系统额外空间

        Object data;        // 生产者线程        new Thread(() -> {            for (; ; ) {                while (data != null) {                    notify consumer;                    wait;                }                data = new Object();            }        }).start();
// 消费者线程 new Thread()->{ while (data == null) { notify consumer; wait; } System.out.printin(data); data = null; }).start();
复制代码

能否优化下呢?我们再来看以下代码,这里一个线程完成了两个线程做的事,而我们将代码改写成了状态机模式,也即通过状态来切换分支,这和线程如此相似。

1、线程需要传入 Runnable 对象,如果是其他语言的读者,可以参考下你熟悉的语言,是不是传入了一个线程执行体(函数指针、或者对象、或者代码块)

2、线程无外乎就是共享数据,不同线程执行不同的代码

3、使用状态+switch ,我们同样生成了对应的分支代码: PROD、CONSUMER 了解到上述知识后,这就是用状态机实现的协程。

二、何为协程

现在我们可以来定义一下,何为协程?

轻量级的线程,拥有自己的执行代码块,但是却不需要系统调用来切换,只需要在用户空间切换。

这不就是增加了性能么?以上的例子是协程的一个改写例子而已。但是我们想要的不是在代码层面对此进行优化,让我们去编写这些高性能代码,我们想要的是在底层默默地把这些事情做了,就像上面的 switch case,让程序员可以像写线程代码一样编写程序,而对于状态机的转换也好,实际的协程实现也好,都由语言本身底层来实现了。看以下 Go 语言的例子,我们只需要同步的编写代码,但是底层可以由协程来完成处理。

如何实现?我们可以用上面的状态机来实现,当然也可以用真正的协程控制块来实现。

三、协程的作用

协程可以帮助我们使用同步代码编写出异步的代码,这一切可以在底层进行实现,比如:socket、write ,该函数为阻塞函数,如果我们使用同步代码,可以使用 NIO+线程池,比如 Epoll+线程池来选择( Epoll 判断通道可写, 那么将写操作放入到线程池中异步写入) , 然后写入,但是我们得编写 callback 回调函数。

但是,如果我们使用协程,你只需要在代码层面这样编写:socket、write ,然后在底层将这一切全部封装,你以为你写的是同步代码,事实上在底层却使用了 NIO+线程池来完成异步性能优化。正如前面的例子那样,原来我需要两个线程,现在我只需要一个线程,然后用状态机实现,你可以在编码层面认为你使用了多线程,比如: go producer 可以完全在底层用一个线程和状态机来完成。

这就是协程的魅力:没有协程,可能你需要使用 NIO+线程池、异步 callback ,但是有了协程,放心写同步代码即可。

1、进程的出现是为了并行/并发执行提高系统性能

2、线程的出现是为了避免使用进程时的空间浪费和时间性能

3、协程的出现是为了帮助开发者简单的编写程序,对底层的线程和进程进行封装

四、协程的实现

在上面的内核中进程切换中我们看到了 shedule 函数,在该函数中完成对进程的切换,在切换时,我们需要保存程序的执行数据,比如:CPU 寄存器(保存在哪?这个知道就行啦,因为不同的进程肯定执行数据不同,至于保存在哪。在后面我们再详细讲解,别忘了现在主题是:协程实现)。这时,我们称 shedule 函数为进程调度函数(同它的命名样)。通过这样的方式我们尝试来推理下,如何实现协程:

1、CPU 中有一个 IP 寄存器(指令指针寄存器,用于指示执行的指令位置)

2、协程和线程都是一个代码执行体(Runnable、代码块、函数指针等等)

3、那么只需要对 IP 寄存器进行保存修改便可以控制 CPU 执行不同位置的代码(注意:这里说的异常简单,事实上还需要保存和修改其他寄存器:四个通用寄存器、段寄存器、堆栈寄存器、EFLAGS 标志位寄存器等等,这里读者先简化为 IP 寄存器即可,后面我会通过混沌学习法给读者展示汇编的学习方式和 CPU 的构造)

4、线程是轻量级进程,在内核中的 schedule 函数中对进程 A 的 IP 保存、然后修改 CPU 的 IP 为 B 进程的指令指针,这样就完成了切换

5、协程呢?同样我们可以在用户态的代码中手动保存 IP 寄存器,然后切换到其他代码执行体的 IP 指针,这样就完成了切换

6、那么如何修改和保存呢(保留点好奇心,后面在介绍 C 语言的方法调用原理时笔者会详细介绍如何存。为了满足好奇心较重的读者,这里先给出结论:调用某个方法时将会把 IP 寄存器的值和 BP 栈基址寄存器压到堆栈中,这时我们就可以得到被切换的进程 A 的下一条执行指令指针 IP,那么 enjoyit)

总结

上面我们给出的推理是向线程一样实现了一个协程,也即协程拥有者自己的协程 ccb(协程控制块),其中包含了协程代码块中的上下文信息:CPU 寄存器的数据。然后我们可以有三种方式来实现:

1、跟内核样,拥有着个调度程序 schedule,来选择调度协程

2、不需要调度函数。只需要跟方法调用一样,拥有者严格调用链关系:A 代码块>B 代码块>C 代码块,这时 C 代码块主动让出 CPU 只能回到 B 代码块,同理 B 也只能回到 A 代码块,C 代码块不能直接回到 A 代码块。这如何实现?还是需要在之后说完 C 的方法调用原理才能讲明白,这里了解下即可(学过这部分知识的读者,可以从 C 的压栈保存 RIP 和 RBP,通过 RSP 和 RBP 开辟新的 C 帧来考虑:A 代码块可以手动操作,将 A 的代码中的 CPU 数据信息放到栈中,然后手动操作 RSP、RBP 开辟 B 代码块的空间,然后修改 RIP 指向 B 代码块执行即可,然后在恢复 A 代码块时,只需要将栈底的 RBP 以下保存的诸多寄存器数据还原,然后最后还原 RIP 即可(当然你也可以不在栈上保存这些 CPU 状态信息,你可以使用 malloc 开辟一个堆空间来保存,取决于你)

3、用 switchcase+状态机这种奇葩的技巧来实现不难看出,这两种方式的共同点:都是切换代码的执行路径,只不过一个跟线程一样,有一一个上下文的控制块,一个直接通过一个变量来切换。如果我们使用方法 1 来实现,这样,我们的协程又有两种实现方式:1.对称协程:所有协程全部平等,在调度函数中对协程进行选择;2.非对称协程:协程之间关系不平等,有着严格调用链的关系,在释放 CPU 资源时,只能回退

到调用方执行


发布于: 刚刚阅读数: 4
用户头像

InfoQ签约作者/技术专家/博客专家 2020.03.20 加入

🏆InfoQ签约作者、CSDN专家博主/Java领域优质创作者、阿里云专家/签约博主、华为云专家、51CTO专家/TOP红人 📫就职某大型金融互联网公司高级工程师 👍专注于研究Liunx内核、Java、源码、架构、设计模式、算法

评论

发布
暂无评论
精通高并发与内核 | Linux内核协程解析_线程_小明Java问道之路_InfoQ写作社区