一、进程和线程

对操作系统进程和线程以及协程的了解,可以看看我前面的文章:

对进程、线程和协程的理解以及它们的区别: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 这样的次序完成。如下图:

image-20230216183601215

如果是多进程,比如有 2 个进程,那么可以把上面的一个大任务分为 2 个小任务,第一个小任务 1 和 2,第二个小任务 3,由 2 个进程完成,如下图:

image-20230216183821397

这就是并发执行。

为什么需要并发执行,要压榨 CPU 让 CPU 发挥最大运算效率,执行更多任务。

在计算机硬件组成中,CPU 的运算速度远远大于其它硬件设备的速度。

image-20230222094505728

1.2 关系协调(进程间通信)

如果只有一个进程,就不需要沟通协调。

如果是多个进程,彼此之间有联系,那么就涉及到进程之间的沟通和协调。涉及沟通,就需要进程间进行"通话"了,这叫进程间通信。

进程间通信一般有 3 方面内容:

  1. 一个进程如何向另外一个进程传递信息。
  2. 多个进程操作共享数据时相互不会产生影响。
  3. 存在依赖关系时确定适当的顺序。

来自:《操作系统设计与实现》

不只多进程,多线程,还有多协程都会有上面 3 方面的内容。

二、协程

在计算机系统中,有一个层次关系,硬件位于最下面,操作系统控制硬件,应用程序运行在操作系统之上,更进一步理解,应用程序是运行在操作系统的用户空间中。

简图如下:

image-20230222161905547

用户创建的多线程/多进程都是运行在用户空间,但调度它们运行是操作系统。

协程也是运行在用户空间,但调度协程运行的是各种语言自己的 runtime,比如 Go 语言的 goroutine 运行在 Go runtime 中。

也就是说在操作系统和调度协程运行之间增加了一个“中间层” - 语言自己的调度系统,比如 Go 语言的 GMP 调度模型,Go runtime 与操作系统线程挂钩进行调度处理,而不是协程直接被操作系统调度处理。

(在计算机中,没有什么是增加一层解决不了的)

Go 中 GMP 和操作系统线程的关系,可以看我这篇文章:https://www.cnblogs.com/jiujuan/p/16193142.html#2888921075 ,里面有一张图画出了它们之间的关系。

gmp-os-thread

三、并发和并行

并发 concurrency 和并行 parallelism。

很多人容易把这 2 者搞混了,认为它们没有区别,其实是有区别。从 CPU 核心个数来理解,就比较容易懂了。

并发

如果 CPU 只有一个核心,那么它就只能并发执行任务,同一个时间只能执行一个任务。如下图:

image-20230222175108850

并行

如果 CPU 有多个核心,那么它就可以并行的执行多个任务,可以在同一时间执行多个任务。

比如 CPU 有 2 个核心,它可以在同一时间同时执行 2 个任务:

image-20230222174917252

而现代计算机往往有多个 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 运行时会检测一些死锁。

  • 活锁

    活锁就是并发的程序都可以运行,但好像彼此都在等待彼此,又都没运行。

  • 饥饿

    与死锁和活锁相似,它是指并发程序无法获得执行工作所需的资源。

六、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")
}

文章如果有不足或错误之处,欢迎大家批评指出,也欢迎大家评论

七、参考