进程、线程与协程
操作系统中,进程与线程的概念相信大家都已熟悉,但是I/O的速度与处理器的速度差异一直是程序性能的瓶颈。
根据高德纳的说法,马尔文·康威于1958年发明了术语“coroutine”并用于构建汇编程序[1] 。这也是协程(coroutine)一词的由来。
现如今,协程逐渐进入各门编程语言视野,Python、Go都有着协程的支持,Java也在最新的Loom项目中将协程作为新的特性支持,协程的重要性可见一斑,本篇内容将从传统的进程线程出发,进而引出协程概念、实现和原理。以及探讨三者不同的适用场景。
(一)进程
随着操作系统的发展,从批处理系统到分时系统、实时系统,操作系统逐渐有了并发、共享、虚拟、异步这四大特征,其中引入进程的目的就是使程序可以进行并发执行。它实现操作系统的并发性和共享性,但是程序也失去了封闭性。
进程是操作系统资源调度的基本单位。需要 注意的是,进程是动态的,它是程序的一次运行过程,它随程序的创建而创建、随程序运行的结束而销毁。就绪态、运行态、阻塞态等。 这就是著名的三态模型。
在OS底层,使用PCB
结构体来维护进程所有信息,例如进程状态、程序内存地址、占用设备以及调度相关信息。
More about Process : 进程-WiKi
(二)线程
试想一下,如果一个程序QQ既需要一边文字聊天有需要一边QQ电话,如果只用进程实现时,只能创建两个独立的进程运行这两个任务。但是发现存在如下几个弊端:
- 创建一个进程所付出的时空开销是巨大的,
- 两个进程所使用的资源环境大部分是相似的,重复数据会造成宝贵的内存资源浪费。
但是引入线程后,我们的QQ主进程可以创建两个线程去执行对应不同的任务,而这两个不同的线程可以共享进程的地址空间和资源,所以这样只需要很少的时空开销。由此可见,线程的引入就是为了提高操作系统的并发性能。
线程有时也被称作是轻量级进程(Lightweight process)。与进程不同,它是一个基本的CPU执行单元,也是执行执行流的最小单元。
线程又可以分为用户级线程和内核级线程,操作系统调度的单位是内核级线程,用户及线程和内核级线程的映射产生了线程模型,有、和三种常见模型,其中JVM中使用了模型,也就是说一个用户级线程对应一个内核级线程。
关于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语言下,协程的存在即简化了开发流程,又大大提升了程序的效率。可以由下面一张图理解普通调用子函数与使用协程调用下的流程图:
如果B()、C()是IO操作呢,我们可以搭配异步IO来进行操作,因此大多时候,如果涉及IO操作的情况,协程一般是和异步I/O来进行搭配实现以提高系统性能的。具体可参考协程是什么-知乎
几个问题
1. 什么时候用进程、什么时候用线程?
从各自的特点来讲:
得益于进程的隔离性,适合在以下情况下使用进程:
- 需要执行独立的任务,彼此之间相对独立。
- 安全性要求高,需要隔离不同任务的运行环境。
- 需要运行在不同的机器上。
得益于线程的资源共享和通信便捷性,适合在以下情况下使用线程:
- 需要同时处理多个相关的任务,彼此之间需要频繁通信和共享数据。
- 资源开销相对较小,可以更高效地利用系统资源。
- 需要响应用户输入或其他事件,同时不阻塞整个程序的运行。
例如采用微服务架构下的大型系统,通常将每个服务编译成单独进程,以便分布式部署。而在一些例如文件下载、图片处理的场景下,通常需要同时对资源进行不同操作,此时线程的通信便捷性就体现出来了,因此该场景下线程更适合。
需要注意:在多线程下,应考虑资源的竞争,需要使用到锁等机制来保证资源安全共享。
2. JDK什么时候支持协程?
按照官方文档,JDK19增加了Virtual Thread(虚拟线程) 特性,这也是Java特色的协程。
可以查看OpenJDK文档
3. 协程到底给我们带来了那些好处
- 协程的切换是由程序显式控制的,避免了传统线程时的上下文切换开销,提高整体性能
- 由于协程的调度时根据开发者控制的,可以根据具体情况具体优化,而不是根据OS底层被动控制
- 更容易实现并发,开发者可以直接使用线程共享的变量,而无需考虑线程同步问题。
- 更好的代码结构,将复杂的异步业务分解为多个子任务,每个协程完成特定的子任务,代码更清晰、可读性更好。
参考资料
- 协程是什么-知乎
- Go goroutine理解
- 一文读懂协程
- 对于协程(coroutine),你必须知道事情都在这里了(内附代码)
- 协程实现
- 深入理解JVM虚拟机-第四版.周志明.12.5.628-630
- 协程-廖雪峰的官方网站