线程的两种实现方式:内核态与用户态线程的切换及竞态分析

托福2025-10-16 16:15:14佚名

线程历史

线程存在两种构建途径:一种是在内核层面实现,另一种是在用户层面构建。在早期阶段,基于内核的线程方案因为原理明确,对编程人员更易于掌握,在与用户层线程的对比中占据了上风。然而,随着互联网技术的进步,用户层线程因其线程转换开销小、并发冲突少等优势重新受到关注,并最终演变为当前最先进的并发架构——协程。接下来,我们将从线程转换和并发冲突这两个角度,分别阐述内核层线程与用户层线程的区别。

从执行体转换的角度来看,执行单元与执行流的核心逻辑大体相同。下图描绘了操作系统线程转换的大致步骤:

现在,线程A正在执行任务。突然,一个定时器信号触发,系统从ring3的线程A切换到ring0的定时器处理程序。如果决定要更换任务,就会把线程A的执行状态信息存入其对应的任务控制结构中。

接下来,依照分配方案从准备执行的进程队列中挑选一个执行,假如决定让进程G开始工作。需要把进程G的状态信息从它的管理结构体里取出,加载到正在工作的进程上。

上下文保存和载入操作执行完毕,程序随即终止,接着切换到ring3层级。在这个层级上运行的应用程序,正是线程F。

从上述流程我们可以发现,内核态线程有以下特点:

这种状况导致内核级线程转换的代价十分高昂,线程数目庞大时,线程转换的负担甚至可能大于实际处理的工作量。

现在,我们观察一下用户级线程的转换步骤,图示呈现了用户级线程转换的大致流程,

现在,线程A正在执行。过了一会儿,线程A自行结束,把它的执行环境信息记录在它的控制结构里。

接下来,线程A依据使用者指令,从其余线程里挑选一个来执行。假如使用者指令规定线程A结束后线程F接着工作,线程A便会将线程F的执行环境信息载入到自身中,然后转向线程F的程序代码部分开始执行。

各用户态线程不断的运行、退出,形成这样一个序列:

通过前文所述内容可知,一旦取消时钟中断机制,某个线程在执行期间无法被强行终止,必须等待其自行结束,其他线程才能继续执行。用户级线程的调度完全取决于各个线程自行选择离开执行状态,以便为其他线程腾出运行空间。所有用户级线程相互配合,共同促进程序的推进,正因如此,这类线程也被称作轻量级进程。

从上述流程我们可以看出,用户态线程有以下特点:

通过上述特征可知,用户线程转换所需资源消耗很小,系统不对用户线程的总数加以控制,因而非常适宜处理大量同时发生的任务。

Linux设有相关支持,以便达成用户空间线程的构建,即所谓user级别实现。该支持通过库完成,其内部结构及方法在头文件.h里进行说明。

库使用结构体表示用户上下文,的定义如下:

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

我们不去探究各个项目的确切意义,仅从实际用途出发,分析其涵盖的内容。具体来说,它包括以下要素:

寄存器和栈信息共同组成了基础的用户级线程环境。该头文件还声明了用于处理用户环境的函数,具体声明方式如下:

获取上下文环境,需要传入一个指向ucontext_t类型的指针参数
设置当前上下文环境需要调用setcontext函数,其参数为指向ucontext_t结构的指针
创建上下文环境需要指定目标函数,同时可以传递参数,具体操作如下,首先确定函数指针,然后准备参数列表,最后调用相关接口完成设置。
创建新上下文环境,然后替换当前上下文环境,使用两个上下文环境指针作为参数

各函数的用途如下:

routine是什么意思_意思是什么意思_意思是的英文

下面,我们通过一个程序演示一下的基本用法。代码如下:

#include 
#include 
ucontext_t uc;
程序入口函数开始执行,参数个数存储在变量中,命令行参数数组传递进来,程序从这里开始运行。
{
        int i = 0;
  
        getcontext(&uc);
  
        printf("hello: %dn", i++);
  
        if (i < 3) {
                setcontext(&uc);
        }
}

或许会感到不解,不过我们仍然能够推测该程序的结果。确实,这个程序展示了一些信息:

hello: 0
hello: 1
hello: 2

调用(&uc)时,会将当前用户的环境信息存入uc变量中,当前用户的环境中的eip指向了下一条指令,也就是下一行,不过需要说明的是,此时这一行还没有被执行。

程序接着运行,一旦处理到指定指令,整个执行情境就复原到被调用指令执行前的状态,然后程序从原位置重新启动。

有一个疑问,在执行(&uc)返回到上一个执行点时,栈上的变量i是否会重置为初始值0。

结果并非如此,原因是示范案例相当基础,并非并发执行,所有程序指令都依托于一个共同的堆栈,不存在堆栈转换,因此堆栈里的数据自然不会发生变动。

示例

现在,我们打算完成一个包含多线程的生产者-消费者范例,以此说明用户空间线程的应用方法。该范例的代码库位置见下文。

这个网址指向一个GitHub上的仓库,里面存放了ucontext的示例代码,通过这个链接可以访问到相关内容。

这个系统由三个主要部分构成:中心控制模块、供应单元和接收单元。中心控制模块负责创建供应单元和接收单元的工作环境,接着它会转换到供应单元开始执行任务。中心控制模块的代码实现如下:

#include 
#include 
  
主程序的用户上下文为uc_main, 它是一个ucontext_t类型的变量, 用于保存主程序的执行状态, 包括寄存器值和栈信息, 在系统调用或中断处理过程中可以保存和恢复主程序的执行上下文, 以实现上下文切换的功能

意思是什么意思_routine是什么意思_意思是的英文

创建一个生产者上下文结构体,命名为uc_producer, 该结构体用于保存生产者的运行环境信息 消费者用户环境变量为uc_consumer, 该变量用于记录消费者上下文信息 int product; 数组 stack_producer 能够存储 1024 乘以 16 个元素,该数组被用作生产者栈 数组 stack_consumer 能够容纳 1024 乘以 16 个元素,它被用作消费者栈 创建生产者函数,该函数不接收任何参数,仅声明其存在 声明了函数consume, 该函数不接受任何参数, 也没有返回值 int main(int argc, char **argv) { getcontext(&uc_producer); uc_producer的uc_stack字段中的ss_sp属性被赋值为stack_producer变量 uc_producer的uc_stack部分,其ss_size字段赋值为stack_producer的数据类型大小 建立上下文环境,目标为生产者函数,初始参数为零 getcontext(&uc_consumer); uc_consumer的uc_stack结构体成员ss_sp被赋值为stack_consumer变量 stack_consumer的长度设定为该数据结构本身的字节大小,uc_consumer的uc_stack字段中的ss_size值被赋予了这个计算结果 创建消费者上下文环境,目标为消费者函数,初始参数设置为0 切换上下文至主用户态环境,随后切换至生产者用户态环境 }

我们建立用户情境时,只能对已有的情境进行变更,不能直接新建一个。所以,第一步是取得当前的用户情境routine是什么意思,然后把它放到生产者的情境变量里。实现方法如下:

getcontext(&uc_producer);

由于制造者必须在分离的线程中执行,因此制造者需要拥有一个专属的栈空间,接下来的代码片段将配置制造者线程使用独立的栈:

uc_producer的uc_stack字段中的ss_sp属性被赋值为stack_producer变量
uc_producer的uc_stack字段中的ss_size属性赋值为stack_producer的数据类型大小

涉及的内容主要是主程序的执行环境,除了要调整栈之外,还需确定当前执行的准确位置,这个位置通常保存在eip寄存器里。现阶段,生产者程序尚未启动,因此只需将生产者的启动地址写入eip寄存器。修改eip寄存器的操作代码如下:

/* makecontext 是一个变参函数
 * 第二个参数为入口函数
 * 第三个参数为参数个数
 * 剩余参数为入口函数的参数
 *

意思是什么意思_意思是的英文_routine是什么意思

创建用户上下文环境需要指定目标函数,可以传递参数,支持可变数量的额外参数,具体操作通过函数指针完成 */ 建立上下文环境,目标为生产者函数,初始参数设置为0,指定上下文结构体为uc_producer

然后,我们用同样的方法为消费者配置用户上下文。代码如下:

getcontext(&uc_consumer);
uc_consumer的uc_stack结构体成员ss_sp被赋值为stack_consumer变量
uc_consumer的uc_stack字段中的ss_size属性赋值为stack_consumer的数据类型大小
建立消费者上下文环境,目标为消费者函数,初始参数设置为0

最终,我们先把当前(主程序线程)的用户环境信息存入其中,接着转换到生产者的用户环境里去。实现方式如下:

切换到主用户上下文环境,然后转向生产者用户上下文环境

调用后,程序将进入生产者中运行。

生产者借助一个逐步增大的计数器来记录进度,每当计数器更新一次,就表明生成了一条新数据。生产者线程在输出每条数据后,会主动让出执行权给消费者线程。下面是生产者部分的实现代码:

#include 
#include 
#include 
  
外部定义了一个上下文结构体变量uc_producer
定义了一个名为uc_consumer的上下文变量
extern int product;
void producer(void)
{
    for (int i = 1; i < 10000; i++) {
      sleep(1);
      product = i;
      printf("[producer] produce %dn", product);
  
      切换上下文至生产者线程,然后切换至消费者线程
    }
}

制造者的核心指令如下,此条指令先将当前制造者线程的环境信息存入变量,接着转换到消费者线程的环境设置。

swapcontext(&uc_producer, &uc_consumer);

生产者可以看作是一种过程,一旦被启动,这个过程并未立刻终止,过程在尚未执行完毕时,就转换去执行其他过程。这一点至关重要,多数的程序(尤其是单内核态线程的程序),过程在退出前都不会转换去执行其他过程。

意思是的英文_routine是什么意思_意思是什么意思

当调用行后,程序将进入消费者中运行。

使用者在一个流程里传送数值,每次传送,就等于耗费了一个信息。使用者进程每耗费一个信息起步网校,就会转换到制造者。使用者程序如下:

#include 
#include 
#include 
  
extern ucontext_t uc_producer;
extern ucontext_t uc_consumer;
extern int product;
void consumer(void)
{
    while (1) {
      sleep(1);
输出方括号消费者字样,消耗数量为产品数量,接着换行
  
切换上下文至消费者线程环境,随后切换至生产者线程环境,完成任务转移。
    }
}

用户的关键指令如下,这一行指令会将当前执行者所处环境信息存入特定存储单元,接着转换到供应方的执行环境。

切换上下文至消费者线程,随后切换至生产者线程。

当调用行后,程序将进入生产者中运行。

运行主体与使用方借助交替释放工作权限并让另一方执行的途径达成了任务级并行处理,运行结果如下:

[producer] produce 1
[consumer] consume 1
[producer] produce 2
[consumer] consume 2
[producer] produce 3
[consumer] consume 3
[producer] produce 4
[consumer] consume 4
[producer] produce 5
[consumer] consume 5

意思是什么意思_意思是的英文_routine是什么意思

这种语言天生具备对并发流程的协助能力,其运作机制依靠一个专用模块来达成,在这个体系中,并发流程被称作是。

系统内部设有精密的分配机制,负责统筹全部任务并规划其执行顺序。该机制位于操作系统层级之上,将系统线程与语言环境的处理单元对应起来,并在这些单元中执行工作。分配机制在任何特定时刻routine是什么意思,都决定着具体哪个任务将在哪个处理单元上推进。

运行时抽象出了三个概念用于描述其调度原理:

G为,也就是用户上下文,包含代码、寄存器、栈等信息。

M为内核态线程,G中的代码就是在M上运行的。

P是逻辑运算单元,担当连接M与G的中介角色。M必须依托P才能执行任务,据此推断G是在P的环境下执行的。P内部存储了部分G的细节,这些G会在该P上得到安排并执行操作。

P、G、M的关系示意图如下:

系统中有(该值通常与CPU核心数相同)个P。

M先从P里取一个G去执行,如果P里的G已经用完了,M就试着从全场的G队列里取一个G来执行。如果全场的G队列里也没有G了,M就向其他P那里借一半的G来执行。如果其他P里也没有G了,M就把这个P标记成空闲,然后M就到线程池里去休息了。

当M察觉到众多G有待执行,自身负荷沉重,同时存在空闲的P。这时M会生成一个新的M或者从线程池中激活一个现有的M,然后将这个M与可用的P对接,以便执行G。

G执行读或写、网络轮询、定时任务等动作时,会引发调度,使该G进入特定状态,该G暂停工作,而P会继续处理其他的G。当读或写、网络轮询、定时任务等动作完成后,相关的G会被移入全局G序列,等待再次调度。

当G实施阻塞性操作时,当前M会与P断开连接。P与其它M建立联系并继续运行G,当前M则静候操作完成。

网络poll操作

这个网络接口,在客户端这边会卡住,这种情况比较符合创造者的想法。在系统执行阶段,它采用epoll技术,实现了非停顿的文件输入输出,这对效率很有好处。比如,接下来的段落展示了一个网络任务,具体写法如下:

conn.Read(buf)
Something()

这段代码从某个地方获取数据,conn.Read在返回之前,不会被调用,这种同步的代码符合开发者的思维,同时,该读取操作的底层使用epoll实现,不会导致当前内核态线程的暂停。

网络操作期间,所有文件描述符均调整为非阻塞模式。若执行IO操作时,IO未就绪,则关联的G会变为特定状态,并转入网络查询模块。当某个网络IO操作成功,该G会被送入全局G队列,等待被分配执行。相关流程图示如下:

阻塞系统调用

若在执行期间触发了某个被阻塞的系统调用,那么M进程就会暂停执行。被阻塞的系统调用结束后,不会像网络poll操作或文件读写那样向调度器发出信号,所以必须等到系统调用完成。在等待系统调用返回期间,与M进程绑定的G线程将无法被调度执行。

为保障并发执行,对系统调用进行了彻底的封装,确保每次进出系统调用时都会引发调度动作。进入系统调用时,会按以下步骤处理:

将产生一个全新的M,解除P和当前M之间的联结,让P与这个新产生的M建立联系,这个新M可能来自一个预先准备好的工作队列中

运用这些措施,P里别的G能够绕过系统调用的阻塞继续运行,而旧的M和G则要等待系统调用的响应。

当系统调用完成之际,老M意识到自己已经不具备P的能力,无法继续进行工作,因此他采取了一系列措施:

将G放回全局G队列进入线程池睡眠

下图展示了执行G3进入阻塞系统调用时调度器的动作过程。具体表现为:

图中所示,运行G3的程序触发了系统调用导致程序停滞。这时会生成或激活(在线程池中)一个M2,M2将与P5关联,接着处理P5里剩余的G任务。M1会暂停等待系统调用结束,调用结束后M1会进入线程池休眠状态,G3会被放入全局G队列中,等待分配执行机会。

抢占式调度

程序执行期间可以记录每个任务的耗时情况,一旦某个任务耗费时间超出预设限度,系统就会为其添加特殊标识。任务在调用函数时,会判断是否带有该标识,一旦确认存在,就会启动调度机制,将当前运行权让渡出去。

从基本原理而言,这种抢占式调度方式尚显粗浅。假如某个G持续执行且期间未发生函数调用行为,那么它将无法被强行剥夺处理机会。实际上,长时间运行却不涉及函数调用的情形十分罕见。

相关推荐

猜你喜欢

大家正在看

换一换