6.四月
1.select/poll/epoll
socket
定位为是linux上的一个api。当然其实各个操作系统上都有实现这样一个“socket”,但是我们一说到操作系统默认就是linux。理解这一点非常重要。
socket翻译过来叫插座,调用它可以创建一个网络连接。所以不如叫他《网络编程》。linux中一切皆文件,所以socket自然就是文件。linux有个概念叫fd(windows里叫句柄,是一个指针),也就是文件描述符,这就是文件的唯一id(直接叫他索引)。准确来说socket是通过linux内核暴露的api来操作linux内核。如果感到抽象,不妨想想前端调用后端的api接口,所谓“内核”有时候听起来很高级,其实只不过就是个相对于调用方的“后端”罢了,所以我归纳为内核就是一个提供了调用接口的功能实现者实体。
最常见来说,网络请求的协议是HTTP。那么HTTP只不过是对TCP的封装,本质上的特性都应该在传输层的TCP来看。为什么提到网络就这么喜欢说TCP?你就当他是个坤哥,后面可能有很多协议,但是本质上只是在模仿他,根本无法撼动“网络协议之父”这种经典的存在。所以在日常也要默认网络通信协议就是TCP。
复习一下,我们认为OS默认是linux,网络协议默认是TCP。再补充一点,从数据库层面看,索引默认是id。
确定一个通信实体(或说是进程?对我来说,实体、对象就是“东西”这个词的文雅说法罢了),需要IP和端口号,也就是我们常见的http://后面带的一串东西。比如http://127.0.0.1:7890就是我们常见的某串东西(doge)。再进一步说。网卡和IP地址是一对一的关系。而端口号是一个IP复用的序列号(对于这种类似于id的唯一标识,我想称他序列号),因为他就默认是1~65535。对的,我们完全可以用常见的编程语言go来说,这个socket其实就类似于NewSocket()这样一个函数,返回了一个网络连接实体,那么他叫socket。怎么去确定另一方通信实体呢?就是一个函数叫做bind()。
更进一步,listen()可以进行监听,accept()可以获取监听中的连接(没错,监听是拿不到连接的,还得主动去接收一下,就类似你收音机信号有了,但是你还得主动去搜查哪些频道是有节目的)。
上面说的是server是接收方,但是client怎么去发起连接?调用connect()即可。当然也需要提供服务端的IP和端口号来确定一个通信的目标实体。
这非常像网关的服务注册。对于client来说,我要注册我自己,我得提供我的IP和端口给对方,然后我也要确定对方的IP和端口才能告诉他我要注册。
后续就是TCP的三次握手了。简单来说我们server有两个队列,一个队列还没三次握手,一个已经三次握手。而后者中的连接实体才是可以通过accept()获取了。
还需要注意监听socket和传输数据的socket是两个实体。当连接建立,就可以用 read()
和 write()
函数来读写数据。
什么是I/O?
os中跨内核态与用户态的读写操作。可以直接简称为读写,是操作文件的input和output。
如何区分多个client连接?
现在我们有一个发送队列和接收队列。他是一直阻塞的,也就是说我发送了,另一方不去拿走(消费),他就残留在队列里。
TCP连接是可以被确定的,就像平面上确定了两个点就可以连出一条线段。我们通过画图展示可爱的四元组。
然后我们就会发现连接数量这个实体,在os里是一个文件。进一步说,连接是占用存储空间的。这就联想到我们的并发问题。也就是说,连接过多了,不就是OOM问题了吗?现在突然就想通了。因此,为了解决这种愚蠢的连接结构带来的愚蠢性能,我们需要采取一些智慧的措施。
首先想到我们可以多个进程,父进程负责监听(获取新链接),当accept()返回一个连接实体,就fork()出来子进程,只需要专注接收消息。那么连接断了,子进程退出即可,那些残留内容(僵尸进程)去做好垃圾回收。但是进程上下文切换资源开销比较大(为什么大?因为用户空间和内核空间的内存堆栈寄存器啥的都需要切换),所以我们想到可以改成多个线程。
一个进程中的线程们共享进程的资源,切换的时候只需要改现成的私有数据和寄存器等即可。
由于创建与销毁线程也需要开销,我们可以提前创建一堆线程。这一坨线程就叫《线程池》(是不是哥嘴里说出来就格外通俗易懂)。
那么按照我们之前说的,有一个已tcp连接的队列,我们的线程池就可以从队列里取出连接实体进行work了。
但线程终究也是实体,它是占用空间的一个员工,一次只让他干一个活太浪费了。有没有什么很贱的办法可以压榨员工,让他干更多活呢?接下来我们来说多路复用。
IO多路复用
让一个线程处理多个TCP连接。
和CPU并发同理,我们可以单个进程轮询多个socket以达到网络层面的并发。有没有注意到,两个形容词都是并发,他们本身就是同一个东西。
select/poll/epoll是三种多路复用模型。
他们也是内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件。
select/poll
select简单流程图如下
select 使用固定长度的 BitsMap 表示文件描述符集合,而且所支持的文件描述符的个数是有限制的。在Linux 系统中,由内核中的 FD_SETSIZE 限制,默认最大值为 1024,只能监听 0~1023 的文件描述符。
poll差不多,但用了链表,它的大小就变得动态,稍微好一点点。
但是二者本质上都是线性遍历,O(n)复杂度,并发数一上来,性能损耗也非常大。
epoll
其实fd再进一步说,在高级语言里,就是类似于一个指针指向一个对象。
这一次,我们用epoll_create 创建一个 epoll对象 epfd,再通过 epoll_ctl 将需要监视的 socket 添加到epfd中,最后调用 epoll wait 等待数据。
等待的伪代码如下
while(1){ |
类似于一直阻塞然后收到socket就处理。
epoll在这个领域就是所谓的《经典》。它有两个核心的数据结构
1.红黑树。对比线性遍历,它的增删改时间复杂度在O(logn)。而且它是长期维护,而不是每次都拷贝整个新的进来。
2.就绪队列。事件回调机制,复杂度在O(1),直接添加到就绪链表(双向链表)而不用拷贝和遍历。本质是一个队列,因此先进先出。
暂时放一张不是很明确的图,之后再优化一下。
补充一下,最大连接数就是最大FD数。
边缘触发和水平触发
理解来说,边缘触发就是循环疯狂读写+非阻塞IO(因为只通知一次或者只消费一次,因此要尽力一次操作完),但是《once通知》;而水平触发就是《multi通知》(反复触发直到被取消)
select/poll只支持水平触发,而epoll支持边缘触发和水平触发,水平触发是默认。边缘触发效率更高,因为避免了重复通知,并且只在状态变化时触发,减少了上下文切换。
2.slice
slice(切片)。
之前我们说到数组。但是在go里面统一使用了一个动态扩缩容的数组——slice。接下来研究一下这个。
关键代码在runtime/slice.go。至于为什么在runtime,因为你直接翻译他就是运行时,是语言自带的,而不是语言的包。
type slice struct { |
slice我们注意到他是一个结构体,因此对他的引用就是一个指针指向头,这和C的数组应该是一样的。
此外,我们的slice包括了len和cap,也就是容量和长度。有人会奇怪这俩不是一样的吗。因为我们slice是动态的,因此我们需要提前去分配内存空间。因此,cap(容量)就是分配的空间,而len是我们数组实际的长度因此一定是有一些位置是空着的。但是为什么空着?因为数组是连续的数据结构,我们需要提前分配连续的空间。如果数组需要加长,而后面的内存有东西,我们则需要再分配一块连续的内存,然后再拷贝过去。这里可以用画图的形式快速展示。
可以看到我们len到达6时,原来分配的内存只有5,不够了,因此需要拷贝到新的空间。扩容机制要求cap翻倍。
值传递
看一段代码
func process(data []int) { |
这时输出的data是没有那个append的1的,因为它只传递了值,没有传递地址。完全可以把他看成传进去了一个结构体,而这个结构体是值传递。解决它只需要在前面加上指针:
func process(data *[]int) { |
扩容机制
cap<len,则扩容,保持len<=cap
1.len<1024,cap翻倍
2.len>=1024,cap为原来125%
对于开发者来说容量的作用是什么?就是我们去new它的时候可以提前设置cap大小,来避免反复扩容带来的开销。
共享内存机制
切片一个特性是可以截取长度,就像python。
a := []int{1, 2, 3} |
这一段代码其实a和b操作的都是同一片内存,所以最后输出就是[1,2,4]
如何避免内存泄漏
内存泄漏就是有东西占用内存未被回收,缺不再被管理,显得被管理的内存越来越少,就是内存向外漏了,所以叫内存泄漏。
1.对于大slice,将不使用部分设置为nil,便于gc回收。
2.截取切片时,显式拷贝(copy()函数)小slice而不是直接截取。
基础操作
随便展示一下,具体怎么写需要自己查一下。
高效拷贝:copy()
添加内容:append()
创建:make()