上一篇文章我总结了进程的调度,现在操作系统为了支持多任务处理引入分时调度,达到在有限的CPU资源下能够运行更多程序;以允许在计算机是运行任意数量的程序,通过抢占式调度器实现分时技术来回换切换程序并且运行,换句话说,它通过在正在运行的进程之间共享CPU时间、快速从一个进程切换到下一个进程来制造多个进程同时运行的假象,这就是基本任务调度处理流程。

但是这种模式是缺点是:进程本身是串行执行的,并且每次时间片用完之后会把当前进程状态保存到PCB中,在把下一个进程PCB加载到寄存器然后继续运行下一个进程,这样做是很消耗时间的,切换内存的会独立分配相关的数据重新分配;为了解决这个问题,后面的操作系统引入了多线程的概念,也就是把进程当做一个容器,而进程内部又引入了新的调度资源概念线程,线程成了CPU成了最小的基本的执行任务命令的单元。

多线程调度

每个进程都有一个线程或多个线程来执行调度任务,在进程内存为线程提供不同的栈,供进程内部的线程来临时存放数据;在内核中,每个线程也有对的内核栈,当线程切换到内核执行时就会使用到内核中栈;

传统多任务只是每个不同的程序进程互相间的并发执行,当线程支持了那么进程内就可以支持多任务并发执行了。例如打开一个浏览器,浏览器打开直接就会在操作系统上跑起来一个进程,而如果在浏览器里面打开单独的页面,这是就是线程间的并发操作了,页面窗口A可以播放视频,页面窗口B可以浏览网页,这就是多线程的应用。

因为内核线程和用户线程是分开的,如果用户线程要去操作一些系统调用那么就要内核线程负责完成这个工作;但是好处就是用户态的线程调度不属于操作系统调度器管理,上图中的用户线程有可以自己实现线程调度器,来控制多个用户线程协调工作。

例如Rust语言中的第三方软件库Tokio,Tokio是Rust编程语言的软件库。它提供了运行时和启用了异步I/O的功能,可以使用它来编写多线程版本的异步运行时,可以运行使用async/await编写的代码,Tokio其实就是标准的Async Rust用户态异步编程功能实现。

多线程模型

在早期的操作系统想要让进程支持多线程并发执行的话,可以使用第三方软件库来完成,可以在程序中编写一个简单的循环来执行不同代码块逻辑实现并发执行,这种模式的用户态线程多个需要调用内核线程的时候就会发生竞争等待情况,因为每次内核线程只能为一个用户态线程服务。

身经百战的开发者一定遇到过几次这样的情况:某个循环无法在开头和结尾判断是否继续进行循环,必须在循环体中间某处控制循环的进行,如果遇到这种情况,我们经常会在一个while (true)循环体里实现中途退出循环的操作,但是在Rust中有一个loop循环可以将整个进程进入真正的死循环状态,并且独占CPU资源;可以通过下面的代码模拟一个代码并行执行逻辑快的操作,如下代码:

// Rust 语言有原生的无限循环结构 —— loop:
fn main() {
    let mut n:i64 = 0;
    loop {
        // action 要重复执行的代码
        if n == 0 {
            println!("播放音乐");
        }
        if n == 1 {
            println!("播放视频");
        }
        if n == 2 {
            println!("下载文件");
        }
        n = (n + 1) % 3;
    }
}

这种方式的缺点很明显:当一部分代码块阻塞主了,其他逻辑代码块也不能执行了,并且阻塞也是整个进程,进程被阻塞就相当于整个程序停止运行;为了解决这个问题操作系统内核也引入了线程概念;

现在大多数操作系统都支持了内核级别的线程,内级的线程是由操作系统管理的,如果用户态调用系统调用那么就需要操作系统内核辅助完成这个,如上图就是线程1:1模型,每个人用户线程调用系统调用不需要等待其他线程占用的情况。每个线程都有自己的对应的内核线程来完成工作,但是缺点很明显用户线程增加,那么内核线程也会增加。往往操作系统会限制用户态线程的数量,如果用户态数量没有限制内核线程也过多,导致整个操作系统性能下降,例如下面代码的线程调度顺序取决于操作系统的调度方式:

use std::thread;
use std::time::Duration;

fn main() {
    thread::spawn(|| {
        for i in 1..5 {
            println!("number is {} from the spawned thread!", i);
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("number is {} from the main thread!", i);
        thread::sleep(Duration::from_millis(1));
    }
}

最后一种线程模型就是N:M,即用户态线程数量要大于内核态的线程数量的关系模型;在多核处理器机器上可以把物理内核线程数设置为M,而用户态用户自己实现的线程数据设置N=M+1,也可以不做限制,这种设计缓解多对一模型中的内核线程过少导致线程阻塞的问题,也减少一对一模型中的内核线程数量随着用户态线程增多导致的数量过多问题,下图就为多对多模型关系图:

但是这种多对多模型的,内核线程调度和用户态线程任务调度管理算法实现起来很复杂,但是如果实现很好线程工作效率也会有很大提升。

加入用户态线程之后那么也有有线程自己状态控制模块即TCB,我之前的文章写过进程的PCB模块,当发送上下文切换的时候保存和还原PCB到程序寄存器里面工作开销很大;线程的TCB只是用户态的状态保存实现,但是如果实现起来很复杂,因为实践到多核的CPU缓存一致性问题,用户态线程的TLS存储数据会在不同的线程里使用的该变量都是副本,访问都是副本这个对应要怎么管理和实际用户态调度来说是个很大考验。

每个TLS都有自己的内存空间,线程库会每个线程创建相同的数据结构,但是内存地址是不一样的,在进程内的全局变量被访问或者操作到了都是在TLS里面的副本,TLS需要访问具体值需要通过偏移量来访问。

POSIX接口

每个线程库的基本接口都是POSIX接口标准来实现的,具体实现的看OS怎么实现这些服务了;在一对一模型中POSIX接口提出了一套标准的接口,让用户开发者来调用创建和管理线程;

Linux下的pthread线程库的实现就是对POSIX标准的实现,例如下面接口作用:

  • pthread_create 提供了创建一个新的线程的函数接口;
  • pthread_exit 可以退出一个线程,默认线程在执行完成任务之后会默认隐式的调用这个函数,还可以指定参数充当返回值;
  • pthread_yield 翻译中文即谦让的意思,顾名思义可以让当成的线程让出CPU分配时间片资源,给其他线程使用;
  • pthread_join 即线程合并操作,例如一个主线程创建一个子线程,关系都是依赖关系,可以通过这个函数接口获取子线程工作状态,判断是否出错如果有错误那么就相应的处理。
  • sleep 接口提供方法让线程休息几分钟,当前线程发现自己的执行任务需要等待其他外部某个事件时,会陷入阻塞状态,可以把自己设置为sleep状态挂起,sleep可以指定时间来控制挂起多久;当时间到了之后内核会唤醒这个线程进行工作。

通过上面的例子sleepyield接口,很明显这两个接口的功能有很多相似之处,都可以让当前的线程主动放弃调度器使用权;但是sleep会让线程直接进入阻塞队列,只有满足了唤醒条件之后才能被恢复到就绪状态。而yield会让线程直接进入就绪状态,当某些极端情况下如果没有其他可以调度的线程,该线程会继续执行。

小结

想玩好多线程并发很困难,如果真的对多线程模型这么做研究的话,这方面需要深挖很多知识,为了解决这些问题,很多其它语言如Java采用特殊的运行时(runtime)软件来协调资源,例如最近Java19要在今年9月要发布的新版本中加入协程的预览版本JEP425虚拟线程功能,如果这个功能添加那么写Java可以使用自己实现多线程模型也可以使用官方提供的协程功能,相关资料的链接:

另外一种就是Go语言,Go语言在用户态实现协程有自己的GMP调度器模型,但是他的GMP实现的协程Go的runtime屏蔽用户自己对真实的线程状态控制权,有利有弊吧;

Rust在这方面做了一些新的尝试,安全高效的处理并发是 Rust 诞生的目的之一,但 Rust 在语言本身就设计了包括所有权机制在内的手段来尽可能地把最常见的错误消灭在编译阶段,但是想玩好Rust本身也需要投入一点时间,本文就写到这着探讨一下多线程并发相关的问题,有兴趣的找找多线程并发或者并行模型相关的论文看看。


参考资料

便宜 VPS vultr
最后修改:2023 年 07 月 05 日
如果觉得我的文章对你有用,请随意赞赏 🌹 谢谢 !