Go语言并发编程(1):对多进程、多线程、协程和并发、并行的理解
一、进程和线程
对操作系统进程和线程以及协程的了解,可以看看我前面的文章:
对进程、线程和协程的理解以及它们的区别:https://www.cnblogs.com/jiujuan/p/16193142.html。
这篇文章我用了多张图片,尽可能清楚表达对它们的理解和区别。
还有一篇 Linux 进程,源码分析,Linux进程: task_struct结构体成员:https://www.cnblogs.com/jiujuan/p/11715853.html
1.1 多进程和多线程
如果是单个进程,单个线程,那么就不涉及到并发。
并发都是涉及到多个进程、多个线程。
为了加快任务的处理,我们可以把一个任务分解成多个小任务进行处理。这就是多任务。
在计算机中,我们可以用多进程或多线程来处理多个任务,这样完成任务的效率就会提高,因为 CPU 运算速度非常快。
比如有一个任务,切菜炒菜后洗衣服:
把这个任务分为 3 个任务,1、切菜 2、炒菜 3、洗衣服,一个进程完成这个任务就按照 1,2,3 这样的次序完成。如下图:
如果是多进程,比如有 2 个进程,那么可以把上面的一个大任务分为 2 个小任务,第一个小任务 1 和 2,第二个小任务 3,由 2 个进程完成,如下图:
这就是并发执行。
为什么需要并发执行,要压榨 CPU 让 CPU 发挥最大运算效率,执行更多任务。
在计算机硬件组成中,CPU 的运算速度远远大于其它硬件设备的速度。
1.2 关系协调(进程间通信)
如果只有一个进程,就不需要沟通协调。
如果是多个进程,彼此之间有联系,那么就涉及到进程之间的沟通和协调。涉及沟通,就需要进程间进行"通话"了,这叫进程间通信。
进程间通信一般有 3 方面内容:
- 一个进程如何向另外一个进程传递信息。
- 多个进程操作共享数据时相互不会产生影响。
- 存在依赖关系时确定适当的顺序。
来自:《操作系统设计与实现》
不只多进程,多线程,还有多协程都会有上面 3 方面的内容。
二、协程
在计算机系统中,有一个层次关系,硬件位于最下面,操作系统控制硬件,应用程序运行在操作系统之上,更进一步理解,应用程序是运行在操作系统的用户空间中。
简图如下:
用户创建的多线程/多进程都是运行在用户空间,但调度它们运行是操作系统。
协程也是运行在用户空间,但调度协程运行的是各种语言自己的 runtime,比如 Go 语言的 goroutine 运行在 Go runtime 中。
也就是说在操作系统和调度协程运行之间增加了一个“中间层” - 语言自己的调度系统,比如 Go 语言的 GMP 调度模型,Go runtime 与操作系统线程挂钩进行调度处理,而不是协程直接被操作系统调度处理。
(在计算机中,没有什么是增加一层解决不了的)
Go 中 GMP 和操作系统线程的关系,可以看我这篇文章:https://www.cnblogs.com/jiujuan/p/16193142.html#2888921075 ,里面有一张图画出了它们之间的关系。
三、并发和并行
并发 concurrency 和并行 parallelism。
很多人容易把这 2 者搞混了,认为它们没有区别,其实是有区别。从 CPU 核心个数来理解,就比较容易懂了。
并发
如果 CPU 只有一个核心,那么它就只能并发执行任务,同一个时间只能执行一个任务。如下图:
并行
如果 CPU 有多个核心,那么它就可以并行的执行多个任务,可以在同一时间执行多个任务。
比如 CPU 有 2 个核心,它可以在同一时间同时执行 2 个任务:
而现代计算机往往有多个 CPU 核心数,Go 语言可以轻松利用多个核心执行程序。而多数编程语言需要写线程同步代码利用多个核,这样容易导致错误。Go 具体是用什么来执行?就是 goroutine。
四、进程/线程/协程间通信相关概念
通信的问题
多个进程线程需要协调,彼此之间就需要通信。那进程间通信一般会有什么问题?在上面第 1.2 小节已经有讲。
-
第一个问题
一个进程把信息传递给另外一个进程
-
第二个问题
两个或多个进程同时操作某一数据时,怎么保证多个进程间彼此不影响。
比如在 12306 购票,你和其他用户抢最后一张票,系统用两个进程执行抢票操作,怎么保证 12306 只卖出最后一张票而不是两张票?
-
第三个问题
与顺序相关。最简单的例子就是打印机,A 进程生产数据,B 进程打印数据,B 打印前必须等待 A 产生一些数据。
竞争条件和临界区
竞争条件 - 两个或多个进程共享读写数据,而最后的结果取决于进程运行的精确时序,称为竞争条件(race condition)。
怎么避免竞争条件?
凡涉及到共享内存、共享文件及共享任何资源都可能引起这种错误,要避免这种错误,关键是要找出某种途径来阻止多个进程同时读写共享的数据。换言之,我们需要互斥,即用某种手段确保当一个进程使用一个共享变量或文件时,其他进程不能做同样的操作。
-- 《现代操作系统》。
我们把共享内存进行访问的程序片段称为临界区或临界区域。如果我们能使两个进程不可能同时处于临界区中,就能避免竞争条件。
忙等待互斥
书中提到了忙等待互斥的几个方法:Peterson 解法和 TSL 和 XCHG,这些解法本质上:当一个进程进入临界区,先检查是否允许进入,若不允许,则该进程将原地等待,直到允许位置。
缺点:浪费 CPU,还可能引起预想不到的结果。如果是等待时间非常短,则可以用。
睡眠与唤醒
进程无法进入临界区时将阻塞,而不是忙等待。
最简单的就是 sleep 和 wakeup。 sleep 是一个将引起系统进程阻塞的调用,即被挂起,直到另外一个进程将其唤醒。
典型应用就是生产者-消费者。
信号量
信号量(semaphore)是 E.W.Dijkstra 在 1965 年提出的一种方法,它使用一个整型变量来累计唤醒次数,供以后使用。
用信号量也可以解决生产者-消费者问题。
互斥量
互斥量是信号量最简化的一个版本,不需要计数。互斥量是一个处于两种状态之一的变量:加锁和解锁。
互斥量使用两个过程:
当一个线程需要访问临界区,它调用 mutex_lock。如果互斥量当前是解锁的(即临界区可用),此调用成功,调用线程可自由进入临界区。
另一方面,如果该互斥量已经加锁,调用线程阻塞,直到在临界区的线程完成并调用 mutex_unlock。
如果多个线程阻塞在互斥量上,将随机选择一个线程并允许它获得锁。
管程和条件变量
Brinch Hansen(1973)和Hoare(1974)提出了一种高级同步原语,称为管程(monitor)。
一个管程是有一个过程、变量及数据结构等组成的一个集合,他们组成一个特殊的包或者软件包。
管程是一种语言的概念。管程有一个很重要的特性,即任一时刻管程中只能有一个活跃进程,这一特性使管程能有效地完成互
斥。管程是编程语言的组成部分,编译器知道它们的特殊性。
进入管程时的互斥由编译器负责,但通常的做法是用一个互斥量或二元信号量。因为是由编译器而非程
序员来安排互斥,所以出错的可能性要小得多。在任一时刻,写管程的人无须关心编译器是如何实现互斥
的。他只需知道将所有的临界区转换成管程过程即可,决不会有两个进程同时执行临界区中的代码
当然解决生成者-消费者,缓存区满的问题,引入了条件变量(condition variables)以及相关2个操作:wait 和 signal。
在管程运行过程中发现生成者满时,它可以在条件变量上执行 wait 操作。该操作会导致调用进程自身阻塞,并且还将另一个以前等在管程之外的进程调入管程。
另外一个进程,比如消费者,还可以唤醒正在睡眠的伙伴进程。这可以通过对其伙伴正在等待的一个条件变量执行signal完成。
消息传递
进程间通信的方法使用两条原语 send 和 receive 。
Go 语言中通过 channel 在 goroutine 之间安全的传递消息。
五、操作系统进程间和Go语言通信方式有哪些
操作系统进程间通信方式
1、管道
2、消息队列
3、共享内存
4、信号量
5、信号
6、socket
1、管道
在 linux 系统的命令行下,运用 shell 命令来操作时,最常用的就是这种通信方式。
ps aux | grep nginx
管道 就是将前一个命令的输出内容作为下一个命令的输入内容,它的功能是一种单向命令传输。
管道又分为:1、 匿名管道 2、命名管道
匿名管道:上面的 shell 例子就是一种匿名管道,它没有名字。
命名管道:与上面相对应的就是有名字的管道,叫命名管道,它是 FIFO 结构,先进先出。
mkfifo pipename # pipename 就是管道的名字,mkfifo 是命令
2、消息队列
linux 内核也实现了消息队列,为进程间消息通信方式之一。
内核消息队列数据结构是一种链表,它里面的每一个数据都是一个单独的数据,叫消息体。
它与我们平时用的 kafka 消息队列作用差不多,是不是?kafka也是作为不同软件间消息通信的中间件,只不过 kafka 消息队列扩展出了很多功能,功能更多更强大。
3、共享内存
共享内存就是 2 个进程操作同一块内存。不需要拷贝来拷贝去的。
4、信号量
信号量其实是一种计数器,主要实现进程间的互斥和同步。
它并不缓存进程间的通信数据。
信号量是对资源计数,它有 2 个操作:
- P 操作:减 1 操作。相减后信号量 < 0,资源被占用尽,进程需要阻塞等待;相减后信号量 >= 0,还有可用资源,进程可进入正常执行。
- V 操作:加 1 操作。相加后信号量 <= 0,当前有阻塞中的进程,唤醒该进程运行;相加后信号量 > 0,当前没有阻塞的进程。
比如,多个进程操作共享内存时,在同一时刻不能有 2 个进程同时写数据到共享内存里,这样操作话,可能数据会发生错误,不是期望的数据。
5、信号
在 linux 中,为了响应各种事件操作,定义了很多信号,不同的信号代表不同的含义。
比如我们最常用的 SIGHUP
就是挂起。
当然还有很多信号,可以通过 kill -l
命令查询所有的信号。
信号
与信号量
虽然只差了一个字,但是两者用途完全不一样,千万别搞混淆了。
6、Socket
socket
用于网络间的通信,不同计算机间之间进程间的通信。
最常用的就是在 TCP/IP
网络编程中。
而上面的 5 种通信方式都是在同一台计算机的进程间进行通信。
Go语言通信方式
Go 语言中采用 CSP 模型来进行通信。
CSP - Communicating Sequential Process,通信顺序进程。
这是一种用于描述两个独立的并发实体通过共享的通讯 Channel(管道)进行通信的并发模型。
在 Go 语言中,关于通信方式有一句非常有名的话:
不要通过共享内存来通信,而应该通过通信来共享内存
六、并发编程常见问题
-
数据竞争
当两个或更多操作必须以正确的顺序执行时,就会出现竞争状态。
-
死锁
所有并发进程都在彼此等待的状态,都不能运行。在这种情况下,如果没有外部干预,程序永远不会恢复。
Go 运行时会检测一些死锁。
-
活锁
活锁就是并发的程序都可以运行,但好像彼此都在等待彼此,又都没运行。
-
饥饿
与死锁和活锁相似,它是指并发程序无法获得执行工作所需的资源。
七、Go协程:Goroutine
goroutine 是 Go 语言中进行并发编程一个很重要的概念。
goroutine 可以与其它 goroutine 并发(不一定是并行)执行函数,同时也会与主程序(main)并行执行,这个就是我们常说的主 goroutine。
goroutine 的一些优点:
- 轻量级,比线程使用内存更少
- 自主调度,效率高。Go 自己的 runtime 在用户空间调度 goroutine,不需要在内核空间和用户空间之间切换
- 利用多核,提高程序执行速度
- 避免阻塞,Go runtime 会监控协程所在的线程是否发生阻塞,如果阻塞,Go runtime 的调度器会把阻塞在线程上的协程调度到没有阻塞的线程上,继续运行。
怎么使用 goroutine?
Go 语言中运行并行编程关键字:go
func main() {
go sayHello()
go func() {
fmt.Println("hello 2")
}()
time.Sleep(3 * time.Second)
}
func sayHello() {
fmt.Println("hello")
}
文章如果有不足或错误之处,欢迎大家批评指出,也欢迎大家评论
八、参考
- 《操作系统设计与实现》作者: (美)ANDREW S.TANENBAUM / ALBERT S.WOODHULL
- 《现代操作系统》 作者:[荷] Andrew S. Tanenbaum / [荷] Herbert Bos