Linux C实现用户态协作式多线程!

皮鞋?湿,不会胖,下雨也不怕!但皮鞋老板不让老湿说协程,那老湿就不说了,毕竟也真的不懂。


前天半夜写下一篇文章作为对九年前一个疑问的回应:
Linux C实现纯用户态抢占式多线程!: https://blog.csdn.net/dog250/article/details/89642905

我自己在该文章的评论中说我这个 纯用户态抢占式多线程 根本就不实用,纯粹是 just for fun, 借这个有意思的事,顺便可以熟悉一下Linux信号处理的底层知识。

我的意思其实是在说, 纯用户态的抢占真的根本就没有意义!!

要理解为什么没有意义,我们首先要明白什么是抢占,以及为什么而抢占。


所谓的抢占,理解起来就是 “在当前执行流事先不知情的情况下,剥夺其执行权限,将执行权切换给另一个执行流。”

那么在什么情况下会发生抢占呢?以及谁有资格执行抢占呢?

最简单的情况,以基于时间片的调度为例,以下是执行抢占的最佳时机:

  • 当前指令流执行了太久,其它等待的指令流过于饥饿的时候;
  • 有更加紧急的指令流刚刚就绪的时候。

我们知道,在我们熟知的几个现代操作系统中, 内核作为一个Monitor是有统观全局的能力的 ,周期性的时钟中断以及各类硬件中断使得内核可以获知一切内部和外部发生的情况,因此内核具备执行抢占的资格!

内核之所以可以拥有这个资格或者说之所以可以拥有这个能力,完全依赖于现代处理器的设计。也许你会认为现代处理器在这里实现了一个多么精巧的机制,那么你就错了,实际上,这个机制非常笨!

现代处理器执行完每一条指令都会检测一下是不是有外部或者内部的重要事件到来,如果有,便以最高优先级调度一段代码来处理,这段代码将打断一切正在执行的指令流!!所以这个机制叫做中断!

现代处理器在执行 一个进程的指令流{inst 0, inst 1, … inst n+m} 的时候看起来是下面的样子:

begin handler:
XXX
end handler
...
inst n
if (event)
    jmp begin handler
inst n+1
if (event)
    jmp begin handler
inst n+2
if (event)
    jmp begin handler
inst n+3
if (event)
    jmp begin handler
inst n+4
if (event)
    jmp begin handler
inst n+5
if (event)
    jmp begin handler
inst n+6
if (event)
    jmp begin handler
inst n+7
if (event)
    jmp begin handler
...
inst n+m
if (event)
    jmp begin handler

是不是很傻呢?是的,很傻。每次一条指令执行完都会判断,如果有重要事件发生,便中断当前的指令流序列的执行,跳到中断处理的代码处去处理中断。

你可能觉得这种每一步都判断的效率太低,但其实这是最高效的方案了。逻辑上的这般处理在物理实现上可以是相当地精巧,但这是电路设计的范畴了…

有了中断机制,操作系统 最高优先级 的内核中断处理程序便可以观察系统的实时快照了。

  • 利用周期到来的时钟中断,内核可以在时钟中断处理程序中判断 当前进程指令流是不是执行太久了?其它等待进程是不是太饥饿了?
  • 利用随机到来的任意硬件中断或者内部异常,内核可以在中断处理程序中判断 是否需要一个相关的进程立即投入运行。
    比如磁盘读完毕中断意味着等待磁盘IO的进程需要立刻运行,网卡收包中断意味着网络服务进程需要更快被唤醒。

这就是内核为什么有资格和能力来执行抢占的原因!


如果非要把这套机制移植到用户态,那未免对进程/线程有点原教旨主义的理解了,包括我自己在内的这批人会觉得 反正只要内核拥有的机制,用户态一定也可以做到。

这不,我在 《Linux C实现纯用户态抢占式多线程! https://blog.csdn.net/dog250/article/details/89642905》 这篇文章中不就是做到了吗?用周期性的信号代替周期性的时钟硬件中断,无非也是找个时间点,让当前的用户态的信号处理程序可以 统观全局,看看是不是要调度了

还可以实现地更好看些,即 创建一个专门的管理线程,或者干脆一个管理进程,实时捕获多个线程的元数据,然后根据自己的判断来发送信号,执行调度。

但是,正如那篇文章所说,虽然实现了,但是却是没有意义的!


为什么没有意义?因为 所有的模拟机制,而不是模拟机制的效果的做法,都是错误的! 当然,这是站在实用主义,结果导向的立场来说的,如果换一个立场,比如在用户态模拟内核机制可以熟悉内核,起到学习的效果,那便是很有正能量了。所以 抛开立场,单纯讨论意义,本身就没有意义!

之所以要实现多线程,是为了 更加有效地利用CPU,使得CPU等待外部事件的时间减少。

目标是 有效利用CPU! 所以最终一切以它为衡量标准。

站在实用主义,结果导向的立场,纯用户态线程被任意时刻抢占毫无意义,它 仅仅能证明用户态有能力做到“抢占”这件事,而不能证明这样做有利于提高CPU利用率! 如果一个线程正在执行密集计算指令,抢占掉它的意义是什么呢?这件事还是交给内核去做比较妥当。

只有用户态线程自己知道什么时候它才要放弃CPU, 用户态进程的多个线程之间是协作关系,而不是竞争关系!这个是在编程时决定的,而不是执行时决定的。

程序员在编程期间会 有意识地 将可以互相协作的指令流拆成不同的线程,而不是像操作系统执行的毫无关联,对对方毫不知情的共同竞争处理器时间的不同进程一样。


下面是一个例子。

我们要完成两个任务:

  1. 从一个无限大的文件中读取数据;
  2. 根据读取的数据进行繁重的计算。

我们很容易想到怎么做:

void func()
{
    while (1) {
        data1 = read_from_file(BLOCK);
        data2 = trasf(data1);
        show = calc(data2);
    }
}

但是我们知道 read_from_file 会阻塞,数据返回期间的处理器时间是不能被利用的,当然,操作系统可以调度别的进程,但是我们现在仅仅考虑本任务而言,不考虑操作系统做的工作。

我们如何能把等待IO的时间利用起来呢?

很简单,等待IO的时候,去执行计算即可。

void func_calc_thread()
{
    while (remian) {
        show = calc(data);
        other1();
    }
    schedule_read_thread
}
void func_read_thread()
{
    while (1) {
        data = read_from_file(non_BLOCK);
        schedule_cacl_thread();
        other2();
    }
}

当然了,这个例子不太好,毕竟不用分解线程只用非阻塞IO也能搞定,由于不太熟悉业务场景,我也不知道怎么去解释例子。反正,我的意思就是说:

用户态多线程之间彼此知道什么情况下需要把执行流交给谁,这是程序员在编程时确定的,它们是协作的关系

所以,用户态多线程最好的方案就是 协作式多线程 。协程也是这个道理,但是我不太懂编程语言,也就不敢多说。


不管什么形式的多线程,都要涉及切换,只要涉及切换,就要涉及寄存器上下文的save/restore。

有个现成的东西,setjmp/longjmp, 但是这个太底层,很不方便,在单stack情况下,longjmp只能从stack的深处跳转到stack的浅处。
【注意⚠️:关于这个参考云风的blog这篇文章 《setjmp 的正确使用》 https://blog.codingnow.com/2010/05/setjmp.html】

除非你不调用函数,否则多线程就一定要用到独立的stack,在设置stack方面,setjmp/longjmp非常之蹩脚!弃用!

像我这种不会编程的想让demo跑起来的唯一方案就是少写代码,能Trick就不编码,能调API就不自己写,总之,能少动手则少动手。

幸运的是,C库提供了ucontext API的封装:

MAKECONTEXT(3) BSD Library Functions Manual MAKECONTEXT(3)

NAME
makecontext, swapcontext – modify and exchange user thread contexts

LIBRARY
Standard C Library (libc, -lc)

SYNOPSIS
#include <ucontext.h>

void makecontext(ucontext_t *ucp, void (*func)(), int argc, …);

int swapcontext(ucontext_t *oucp, const ucontext_t *ucp);

DESCRIPTION
The makecontext() function modifies the user thread context pointed to by ucp, which must have previously been initialized by a call to getcontext(3) and had a stack allocated
for it. The context is modified so that it will continue execution by invoking func() with the arguments (of type int) provided. The argc argument must be equal to the number
of additional arguments provided to makecontext() and also equal to the number of arguments to func(), or else the behavior is undefined.

The ucp->uc_link argument must be initialized before calling makecontext() and determines the action to take when func() returns: if equal to NULL, the process exits; other-
wise, setcontext(ucp->uc_link) is implicitly invoked.

The swapcontext() function saves the current thread context in *oucp and makes *ucp the currently active context.

这个ucontext结构体贴出来,会比较熟悉,它保存了当前 “线程” 的上下文:

typedef struct ucontext
{
    unsigned long int uc_flags;
    struct ucontext *uc_link;
    stack_t uc_stack;
    mcontext_t uc_mcontext;
    __sigset_t uc_sigmask;
    struct _fpstate __fpregs_mem;
} ucontext_t;

Linux的信号处理也用到了这个结构。但是本文,实现的协作式多线程中,将不再使用任何内核机制,完全用这个结构体来切换不同的线程。

直接看我的demo吧:

#include <ucontext.h>
#include <stdio.h>
#include <stdlib.h>

static ucontext_t _main;
static ucontext_t uctx[3];
char stack1[4096];
char stack2[4096];
char stack3[4096];

static int idx = -1;

// 调度切换例程
// 1. 目前采用轮转调度,比较简单
// 2. 如果采用策略调度,则需要保存curr元数据,精确定位swapcontext第一个参数为当前线程的context
void schedule()
{
    idx ++;
    if (swapcontext(&uctx[idx%3], &uctx[(idx+1)%3]) == -1) {
        exit(-1);
    }
}

// 此下3个函数为3个线程的线程处理函数,各自打印各自的序列。
static void thread1(void)
{
    int i = 1;

    while (i++) {
        printf("Thread 1, working:%d\n", i);
        sleep(1);
        if (i%3 == 0) {
            // 线程意识到自己该切换的时候,自主切换
            schedule();
        }
    }
}

static void thread2(void)
{
    int i = 0xfff;

    while (i++) {
        printf("Thread 2, working:%d\n", i);
        sleep(1);
        if (i%3 == 0) {
            schedule();
        }
    }
}

static void thread3(void)
{
    int i = 0xfffff;

    while (i++) {
        printf("Thread 3, working:%d\n", i);
        sleep(1);
        if (i%3 == 0) {
            schedule();
        }
    }
}

int main(int argc, char *argv[])
{
    // 初始化3个线程的上下文结构体,注意,一定要独立堆栈。
    getcontext(&uctx[0]);
    uctx[0].uc_stack.ss_sp = stack1;
    uctx[0].uc_stack.ss_size = sizeof(stack1);
    makecontext(&uctx[0], thread1, 0);

    getcontext(&uctx[1]);
    uctx[1].uc_stack.ss_sp = stack2;
    uctx[1].uc_stack.ss_size = sizeof(stack2);
    makecontext(&uctx[1], thread2, 0);

    getcontext(&uctx[2]);
    uctx[2].uc_stack.ss_sp = stack3;
    uctx[2].uc_stack.ss_size = sizeof(stack3);
    makecontext(&uctx[2], thread3, 0);

    printf("start thread 1\n");
    swapcontext(&_main, &uctx[0]);

    return 0;
}

效果如下:

[root@localhost ~]# ./a.out
start thread 1
Thread 1, working:2
Thread 1, working:3
Thread 2, working:4096
Thread 2, working:4097
Thread 2, working:4098
Thread 3, working:1048576
Thread 3, working:1048577
Thread 3, working:1048578
Thread 1, working:4
Thread 1, working:5
Thread 1, working:6
Thread 2, working:4099
Thread 2, working:4100
Thread 2, working:4101
Thread 3, working:1048579
Thread 3, working:1048580
Thread 3, working:1048581
Thread 1, working:7
Thread 1, working:8
Thread 1, working:9
Thread 2, working:4102
Thread 2, working:4103
Thread 2, working:4104
Thread 3, working:1048582
Thread 3, working:1048583
Thread 3, working:1048584

效果不错!

但是,且慢!


我还是中毒太深了。我怎么能在schedule的注释里面写下:

// 调度切换例程
// 1. 目前采用轮转调度,比较简单
// 2. 如果采用策略调度,则需要保存curr元数据,精确定位swapcontext第一个参数为当前线程的context

这种话呢?

  • 既然是自主的调度。
  • 既然是协作式的调度。
  • 既然每个线程都知道什么时候哪个线程运行更合适。

还干嘛要schedule程序自己调度啊!如果schedule函数自己调度,那就意味着schedule函数自己知道全局信息,它是调度的主宰,那么这完全是抢占式的思路啊!内核就是这么做的。

用户态线程调度,正确的做法应该是线程告诉调度器如何来切换啊!因此修改如下:

// 参数指示如何调度
void schedule(int prev, int next)
{
    if (swapcontext(&uctx[prev], &uctx[next]) == -1) {
        exit(-1);
    }
}

static void thread1(void)
{
    int i = 1;

    while (i++) {
        printf("Thread 1, working:%d\n", i);
        sleep(1);
        if (i%3 == 0) {
            // 线程自己决定将自己切换到谁
            schedule(0, 1);
        }
    }
}

嗯,我们实现了一个 协作式多线程 的框架,虽然简陋,但是至少是成功了。为了防止被喷,我说,这并不实用,这没什么意义,只是为了熟悉底层系统…哦,对了,这里甚至没有涉及到内核,所以也就不必为触动什么底层系统而傲慢了。对,说的就是我自己。

结论该下达了。

结论就是,如果不借助操作系统底层的机制,仅仅凭借用户态自身, 完全无法实现抢占式多线程!而只能实现协作式多线程!

定时的时间中断服务,普通设备中断服务,都是操作系统提供的,操作系统并不是一个普通的程序,和普通的程序相比,操作系统真的就是额外的!普通的程序就是一个程序,它们没有什么多线程,多道程序的概念,它们就是 一个执行流 而已!然而,操作系统却是一个虚拟机!它是普通进程的容器或者说舞台!

现代操作系统的概念才不过30多年,然而它的理念却让我们觉得是那么的理所当然。

在一个普通进程看来,它没有中断的概念,它只能轮询。它不能正在执行指令序列时突然无条件转到其它的执行流,即便是if-else也必然是主动的调用,而不是被动的跳转。

那么,既然在程序进程自身的范畴内,没有办法让其被其它用户态线程抢占,但是我们可以借助于编译器!正如上文所说,多线程对于程序员而言是知情的。

既然对于程序员知情,那么对于编译器而言,更加知情了。


在编译程序的时候,编译器可以 按照指令的数量,比如每间隔10条指令,显式插入一条 call schedule指令,仅此而已! 据说Go,Erlang就是这么搞的。

即便是不知道有ucontext系列的调用,我们手工save/restore线程的上下文依然也做得到。


下雨,经理的皮鞋湿了,经理的皮鞋胖了,然后,五一假期到了。

原文链接: https://blog.csdn.net/dog250/article/details/89709706

欢迎关注

微信关注下方公众号,第一时间获取干货硬货;公众号内回复【pdf】免费获取数百本计算机经典书籍;

也有高质量的技术群,里面有嵌入式、搜广推等BAT大佬

    Linux C实现用户态协作式多线程!

原创文章受到原创版权保护。转载请注明出处:https://www.ccppcoding.com/archives/406585

非原创文章文中已经注明原地址,如有侵权,联系删除

关注公众号【高性能架构探索】,第一时间获取最新文章

转载文章受原作者版权保护。转载请注明原作者出处!

(0)
上一篇 2023年4月26日 上午9:56
下一篇 2023年4月26日 上午9:56

相关推荐