c04_08.rst 9.4 KB
Newer Older
写代码的明哥's avatar
写代码的明哥 已提交
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
4.8 学习一些常见的并发模型
==========================

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

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

0. 并发与并行
-------------

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

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

-  **并发**\ :当你在跑步时,发现鞋带松,要停下来系鞋带,这时候跑步和系鞋带就是并发状态。
-  **并行**\ :你跑步时,可以同时听歌,那么跑步和听歌就是并行状态,谁也不影响谁。

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

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

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

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

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

1. 并发编程的模型
-----------------

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

写代码的明哥's avatar
写代码的明哥 已提交
38 39
1. 多进程模型:创建新的进程处理请求
2. 多线程模型:创建新的线程处理请求
写代码的明哥's avatar
写代码的明哥 已提交
40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188
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()

参考阅读:
----------

https://www.cnblogs.com/aspirant/p/9166944.html

https://blog.csdn.net/snoweaglelord/article/details/99681179

https://www.jianshu.com/p/dfd940e7fca2

https://studygolang.com/articles/13344

--------------

|image0|

189
.. |image0| image:: http://image.python-online.cn/image-20200320125724880.png
190