4.8 学习一些常见的并发模型

image0

本篇内容主要是了解下并发编程中的一些概念,及讲述一些常用的并发模型都是什么样的,从而理解 Golang 中的 协程在这些众多模型中是一种什么样的存在及地位。可能和本系列的初衷(零基础学Go)有所出入,因此你读不读本篇都不会对你学习Go有影响,尽管我个人觉得这是有必要了解的。

你可以自行选择,若你只想学习 Golang 有关的内容,完全可以跳过本篇。

0. 并发与并行

讲到并发,那不防先了解下什么是并发,与之相对的并行有什么区别?

这里我用两个例子来形象描述:

  • 并发:当你在跑步时,发现鞋带松,要停下来系鞋带,这时候跑步和系鞋带就是并发状态。

  • 并行:你跑步时,可以同时听歌,那么跑步和听歌就是并行状态,谁也不影响谁。

在计算机的世界中,一个CPU核严格来说同一时刻只能做一件事,但由于CPU的频率实在太快了,人们根本感知不到其切换的过程,所以我们在编码的时候,实际上是可以在单核机器上写多进程的程序(但你要知道这是假象),这是相对意义上的并行。

而当你的机器有多个 CPU 核时,多个进程之间才能真正的实现并行,这是绝对意义上的并行。

接着来说并发,所谓的并发,就是多个任务之间可以在同一时间段里一起执行。

但是在单核CPU里,他同一时刻只能做一件事情 ,怎么办?

谁都不能偏坦,我就先做一会 A 的活,再做一会B 的活,接着去做一会 C 的活,然后再去做一会 A 的活,就这样不断的切换着,大家都很开心,其乐融融。

1. 并发编程的模型

在计算机的世界里,实现并发通常有几种方式:

  1. 多进程模型:创建新的进程处理请求

  2. 多线程模型:创建新的线程处理请求

  3. 使用线程池:线程/进程创建销毁开销大

  4. I/O 多路复用+单/多线程

2. 多进程与多线程

对于普通的用户来说,进程是最熟悉的存在,比如一个 QQ ,一个微信,它们都是一个进程。

进程是计算机资源分配的最小单位,而线程是比进程更小的执行单元,它不能脱离于进程单独存在。

在一个进程里,至少有一个线程,那个线程叫主线程,同时你也可以创建多个线程,多个线程之间是可以并发执行的。

线程是调度的基本单位,在多线程里,在调度过程中,需要由 CPU 和 内核层参与上下文的切换。如果你跑了A线程,然后切到B线程,内核调用开始,CPU需要对A线程的上下文保留,然后切到B线程,然后把控制权交给你的应用层调度。

而进程的切换,相比线程来说,会更加麻烦。

因为进程有自己的独立地址空间,多个进程之间的地址空间是相互隔离的,这和线程有很大的不同,单个进程内的多个线程 共享进程中的数据的,使用相同的地址空间,所以CPU切换一个线程的花费远比进程要小很多,同时创建一个线程的开销也比进程要小很多。

此外,由于同一进程下的线程共享全局变量、静态变量等数据,使得线程间的通信非常方便,相比之下,进程间的通信(IPC,InterProcess Communication)就略显复杂,通常的进程间的通信方式有:管道,消息队列,信号量,Socket,Streams 等

说了这么多,好像都在说线程优于进程,也不尽然。

比如多线程更多用于有IO密集型的业务场景,而对于计算密集型的场景,应该优先选择多进程。

同时,多进程程序更健壮,多线程程序只要有一个线程死掉,整个进程也死掉了,而一个进程死掉并不会对另外一个进程造成影响,因为进程有自己独立的地址空间。

3. I/O多路复用

I/O多路复用 ,英文全称为 I/O multiplexing,这个中文翻译和把 socket 翻译成 套接字一样,影响了我对其概念的理解。

在互联网早期,为了实现一个服务器可以处理多个客户端的连接,程序猿是这样做的。服务器得知来了一个请求后,就去创建一个线程处理这个请求,假如有10个客户请求,就创建10个线程,这在当时联网设备还比较匮乏的时代,是没有任何问题的。

但随着科技的发展,人们越来越富裕,都买得起电脑了,网民也越来越多了,由于一台机器的能开启的线程数是有限制的,当请求非常集中量大到一定量时,服务器的压力就巨大无比。

终于到了 1983年,人们意识到这种问题,提出了一种最早的 I/O 多路复用的模型(select实现),这种模型,对比之前最大的不同就是,处理请求的线程不再是根据请求来定,后端请求的进程只有一个。虽然这种模型在现在看来还是不行,但在当时已经大大减小了服务器系统的开销,可以解决服务器压力太大的问题,毕竟当时的电脑都是很珍贵的。

再后来,家家都有了电脑,手机互联网的时代也要开始来了,联网设备爆炸式增长,之前的 select ,早已不能支撑用户请求了。

由于使用 select 最多只能接收 1024 个连接,后来程序猿们又改进了 select 发明了 pool,pool 使用的链表存储,没有最大连接数的限制。

select 和 pool ,除了解决了连接数的限制 ,其他似乎没有本质的区别。

都是服务器知道了有一个连接来了,由于并不知道是哪那几个流(可能有一个,多个,甚至全部),所以只能一个一个查过去(轮循),假如服务器上有几万个文件描述符(下称fd,file descriptor),而你要处理一个请求,却要遍历几万个fd,这样是不是很浪费时间和资源。

由此程序员不得不持续改进 I/O多路复用的策略,这才有了后来的 epoll 方法。

epoll 解决了前期 select 和 poll 出现的一系列的尴尬问题,比如:

  • select 和 poll 无差别轮循fd,浪费资源,epool 使用通知回调机制,有流发生 IO事件时就会主动触发回调函数

  • select 和 poll 线程不安全,epool 线程安全

  • select 请求连接数的限制,epool 能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口)

  • select 和 pool 需要频繁地将fd复制到内核空间,开销大,epoll通过内核和用户空间共享一块内存来减少这方面的开销。

虽然 I/O 多路复用经历了三种实现:select -> pool -> epool,这也不是就说 epool 出现了, select 就会被淘汰掉。

epool 关注的是活跃的连接数,当连接数非常多但活跃连接少的情况下(比如长连接数较多),epool 的性能最好。

而 select 关注的是连接总数,当连接数多而且大部分的连接都很活跃的情况下,选择 select 会更好,因为 epool 的通知回调机制需要很多的函数回调。

另外还有一点是,select 是 POSIX 规定的,一般操作系统均有实现,而 epool 是 Linux 所有的,其他平台上没有。

IO多路复用除了以上三种不同的具体实现的区别外,还可以根据线程数的多少来分类

  • 一个线程的IO多路复用,比如 Redis

  • 多个线程的IO多路复用,比如 goroutine

IO多路复用 + 单进(线)程有个好处,就是不会有并发编程的各种坑问题,比如在nginx里,redis里,编程实现都会很简单很多。编程中处理并发冲突和一致性,原子性问题真的是很难,极易出错。

4. 三种线程模型?

实际上,goroutine 并非传统意义上的协程。

现在主流的线程模型分三种:

  • 内核级线程模型

  • 用户级线程模型

  • 两级线程模型(也称混合型线程模型)

传统的协程库属于用户级线程模型,而 goroutine 和它的 Go Scheduler 在底层实现上其实是属于两级线程模型,因此,有时候为了方便理解可以简单把 goroutine 类比成协程,但心里一定要有个清晰的认知 — goroutine并不等同于协程。

关于这块,想详细了解的,可以前往:https://studygolang.com/articles/13344

5. 协程的优势在哪?

协程,可以认为是轻量级的“线程”。

对比线程,有如下几个明显的优势。

  1. 协程的调度由 Go 的 runtime 管理,协程切换不需要经由操作系统内核,开销较小。

  2. 单个协程的堆栈只有几个kb,可创建协程的数量远超线程数。

同时,在 Golang 里,我还体会到了这种现代化编程语言带来的优势,它考虑得面面俱到,让编码变得更加的傻瓜式,goroutine的定义不需要在定义时区分是否异步函数(相对Python的 async def 而言),运行时只需要一个关键字 go,就可以轻松创建一个协程。

使用 -race 来检测数据 访问的冲突

协程什么时候会切换

  1. I/O,select

  2. channel

  3. 等待锁

  4. 函数调用(有时

  5. runtime.Gosched()