浅谈OS之进程、线程与协程

浅谈OS之进程、线程与协程

Tans 2,397 2022-07-07

进程、线程与协程

操作系统中,进程与线程的概念相信大家都已熟悉,但是I/O的速度与处理器的速度差异一直是程序性能的瓶颈

根据高德纳的说法,马尔文·康威于1958年发明了术语“coroutine”并用于构建汇编程序[1] 。这也是协程(coroutine)一词的由来。

现如今,协程逐渐进入各门编程语言视野,Python、Go都有着协程的支持,Java也在最新的Loom项目中将协程作为新的特性支持,协程的重要性可见一斑,本篇内容将从传统的进程线程出发,进而引出协程概念、实现和原理。以及探讨三者不同的适用场景。

1_sLSgHWoJaMqvDvY7ubSrUQ@2x

上图展示三者的简要关系,可见一个进程可以包括多个线程,一个线程包括多个协程。

(一)进程

随着操作系统的发展,从批处理系统到分时系统、实时系统,操作系统逐渐有了并发、共享、虚拟、异步这四大特征,其中引入进程的目的就是使程序可以进行并发执行。它实现操作系统的并发性和共享性,但是程序也失去了封闭性

进程是操作系统资源调度的基本单位。需要 注意的是,进程是动态的,它是程序的一次运行过程,它随程序的创建而创建、随程序运行的结束而销毁。就绪态、运行态、阻塞态等。 这就是著名的三态模型
image-1691069923298

在OS底层,使用PCB结构体来维护进程所有信息,例如进程状态、程序内存地址、占用设备以及调度相关信息。

More about Process : 进程-WiKi

(二)线程

试想一下,如果一个程序QQ既需要一边文字聊天有需要一边QQ电话,如果只用进程实现时,只能创建两个独立的进程运行这两个任务。但是发现存在如下几个弊端:

  • 创建一个进程所付出的时空开销是巨大的,
  • 两个进程所使用的资源环境大部分是相似的,重复数据会造成宝贵的内存资源浪费

但是引入线程后,我们的QQ主进程可以创建两个线程去执行对应不同的任务,而这两个不同的线程可以共享进程的地址空间和资源,所以这样只需要很少的时空开销。由此可见,线程的引入就是为了提高操作系统的并发性能。

线程有时也被称作是轻量级进程(Lightweight process)。与进程不同,它是一个基本的CPU执行单元,也是执行执行流的最小单元

线程又可以分为用户级线程和内核级线程,操作系统调度的单位是内核级线程,用户及线程和内核级线程的映射产生了线程模型,有m:1m:11:11:1m:nm : n三种常见模型,其中JVM中使用了1:11:1模型,也就是说一个用户级线程对应一个内核级线程。

关于JVM中所使用的线程模型:JDK1.2之前,是基于一种被称为“绿色线程”的用户线程实现的,JDK1.3之后开始基于系统原生进程模型来实现,采用1:1的线程模型。以HotSpot为例,它的每一个Java线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构,所以HotSpot自己是不会去干涉线程调度的

”操作系统所支持的线程模型通常约束着Java虚拟机的线程模型,因此JVM虚拟机规范不去约束采用哪种线程模型“ —《深入理解JVM虚拟机第四版》

(三)协程

无论是Java中亟待公之于众的Loom项目、亦是Golang中原生支持的goroutine,可以看出各种语言都在积极拥抱协程(coroutine)。

协程又称作微线程,纤程,英文名Coroutine。简单来说可以认为协程是线程里不同的函数,这些函数之间可以相互快速切换。

通过上文知道,内核调度的成本主要来自于用户态到内核态转换,而运行在线程之上的协程只由用户程序进行调度管理,这样调度的开销大大缩减了。下面是线程与协程的比较:

比较 线程 协程
占用资源 初始单位为1MB 初始一般为2KB
调度器 OS Kernel User
切换开销 模式切换(PSW)、寄存器、PC、SP等寄存器 PC、SP、DX
性能问题 资源占用太高,频繁创建销毁带来性能问题 占用资源小
数据同步 需要锁机制来确保数据的一致性和可见性 不需要多线程的锁机制,因为只有一个线程,不存在写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比较高
线程与协程的关系

一个消费者-生产例子如下:在无协程的情况下,我们通常需要开启两个线程来实现,往往也需要进行加锁等操作,但是拥有的协程的情况下,一个线程足矣,同时我们也不需要进行加锁处理,只需要判断状态即可。

var q := new queue
coroutine produce
    loop
        while q is not full
            create some new items
            add the items to q
        yield to consume
     
coroutine consume
    loop
        while q is not empty
            remove some items from q
            use the items
        yield to produce

协程在再来看一个问题:

func main() {
     // 语句 1
     //B()
     //C()
     // 语句 2
	go B()
	go C()
	time.Sleep(2 * time.Second)
}

func B() {
	for i := 0; i < 5; i++ {
		fmt.Println("1")
		time.Sleep(10)
	}
}
func C() {
	for i := 0; i < 5; i++ {
		fmt.Println("2")
		time.Sleep(10)
	}
}

如果执行main函数,那么执行结果如下:

  • 如果执行语句1,打印1111122222
  • 如果执行语句2,会交替打印 1122112122….

可以看出,在Go语言下,协程的存在即简化了开发流程,又大大提升了程序的效率。可以由下面一张图理解普通调用子函数与使用协程调用下的流程图:

1_-XEyhxsLhslrDW9iXMiKUg@2x

如果B()、C()是IO操作呢,我们可以搭配异步IO来进行操作,因此大多时候,如果涉及IO操作的情况,协程一般是和异步I/O来进行搭配实现以提高系统性能的。具体可参考协程是什么-知乎

几个问题

1. 什么时候用进程、什么时候用线程?

从各自的特点来讲:

得益于进程的隔离性,适合在以下情况下使用进程:

  • 需要执行独立的任务,彼此之间相对独立。
  • 安全性要求高,需要隔离不同任务的运行环境。
  • 需要运行在不同的机器上。

得益于线程的资源共享和通信便捷性,适合在以下情况下使用线程:

  • 需要同时处理多个相关的任务,彼此之间需要频繁通信和共享数据。
  • 资源开销相对较小,可以更高效地利用系统资源。
  • 需要响应用户输入或其他事件,同时不阻塞整个程序的运行。

例如采用微服务架构下的大型系统,通常将每个服务编译成单独进程,以便分布式部署。而在一些例如文件下载、图片处理的场景下,通常需要同时对资源进行不同操作,此时线程的通信便捷性就体现出来了,因此该场景下线程更适合。

需要注意:在多线程下,应考虑资源的竞争,需要使用到锁等机制来保证资源安全共享。

2. JDK什么时候支持协程?

按照官方文档,JDK19增加了Virtual Thread(虚拟线程) 特性,这也是Java特色的协程。
可以查看OpenJDK文档

3. 协程到底给我们带来了那些好处

  1. 协程的切换是由程序显式控制的,避免了传统线程时的上下文切换开销,提高整体性能
  2. 由于协程的调度时根据开发者控制的,可以根据具体情况具体优化,而不是根据OS底层被动控制
  3. 更容易实现并发,开发者可以直接使用线程共享的变量,而无需考虑线程同步问题。
  4. 更好的代码结构,将复杂的异步业务分解为多个子任务,每个协程完成特定的子任务,代码更清晰、可读性更好。

参考资料

  1. 协程是什么-知乎
  2. Go goroutine理解
  3. 一文读懂协程
  4. 对于协程(coroutine),你必须知道事情都在这里了(内附代码)
  5. 协程实现
  6. 深入理解JVM虚拟机-第四版.周志明.12.5.628-630
  7. 协程-廖雪峰的官方网站