Socket
Linux一切皆文件的概念,都可以用“打开open –> 读写write/read –> 关闭close”模式来操作。Socket就是该模式的一个实现,socket即是一种特殊的文件,一些socket函数就是对其进行的操作(读/写IO、打开、关闭),这些函数我们在后面进行介绍。
想要客户端和服务端在网络间通信则必须使用Socket
,支持跨主机通信
在建立两端通信时,需要将客户端和服务端都创建一个Socket
,像一条线通过Socket
连接起两端主机,Socket
创建时可以指定使用的网络协议,通常情况下是使用TCP
和UDP
,而相对来说TCP
的场景偏多一些
如何创建两端通信呢?首先,服务端调用 socket()
函数,创建网络协议IPV4,传输协议为TCP
的Socket
,接着调用 bind()
函数给这个Socket
绑定IP和端口,接着会调用 listen()
函数监听连接接入,通过 accept()
函数等待连接连入,如无客户端连接则阻塞,客户端则在创建好 Socket
后调用 connect()
函数发起连接,指定服务端的IP及端口,然后通过TCP
的三次握手后连接建立完成
这个连接过程中,监听和连接的Socket
实际上是两个Socket

通过上图可以发现,Socket
本质上与文件传输很接近,而Linux一切皆文件的概念,Socket
本质上也是文件,而上面这种Socket
通信,是最简单的一对一通信,当服务端未处理完一个客户端的网络I/O时则会阻塞,而这个时候其他客户端是无法连接的,只能等待
多进程模型
基于上述的Socket
阻塞传输I/O,衍生出了多进程模型,即为每个连接进来的客户端都分配一个进程去处理,阻塞也只阻塞当前进程,服务器主进程通过accept()
监听客户端连接,当接收到连接后,调用系统fork()
函数创建子进程,将父进程的一切内容复制到子进程中,这种复制更像是一种指针,通过子进程来继续与客户端通信,待子进程处理完后再返回给父进程,通过不同的返回值,子进程为 0
。

这种做法很容易出现垃圾无法回收的问题,子进程无法销毁,因此父进程可以调用wait()
和waitpid()
来回收子进程
这种多进程模型的缺点在于,不断地fork子进程会造成大量的进程间上下文切换,而且进程并不足够轻量
,每次创建进程则也需要创建对应的 堆栈.寄存器等一系列资源
多线程模型
通过上面的多进程模型理念可以看出,不够轻量
会导致更多的负载,那么后续又衍生出了多线程模型的概念,既然进程不满足那么就从多线程去下手
通过线程池
的概念,连接连接进来时,将已连接的Socket
塞到Socket
等待队列中,另一边从线程池不断地取出已连接的Socket
,然后创建线程交给线程去处理

结合之前的文章,实际应用中,每个创建的线程创建后都需要加锁,避免资源竞争问题
而这种多线程的做法很大程度上提高了一些效率,也减少了上下文切换的资源消耗问题,但是当数量很大的请求过来时,维护一个超大数量的线程池,或者说给线程池加上限,这并不是一个很好的做法,而维护也更麻烦,调度可能就会宕机,调度宕机也就面临着不可预测的问题。
I/O多路复用
多路复用的概念则是从源头出发,这种一对一的方式显然不是最好的,那么就衍生出了 一个进程/线程 对应多个Socket的技术
一个进程同时只能处理一个Socket
,但是如果将这个任务时间控制到毫秒微妙级时,将多个Socket
都指向同一个进程则是多路复用,他与CPU的并发模型很接近,也被称为时分多路复用
I/O 复用其实复用的不是 I/O 连接,而是复用线程,让一个 thread of control 能够处理多个连接(I/O 事件)

select
将已连接的Socket
放到一个文件描述集合(类指针)
中,调用select
函数将集合拷贝到内核中,内核遍历其中的Socket
是否有事件产生,然后将其标记为可读
,可写
标识,将改过的集合送回用户态,然后用户态则继续遍历找到可读
,可写
的Socket
对其处理
select
使用bitsmap
标识文件描述集合
,所支持的描述符个数有限制,在Linux中的FD_SETSIZE
限制,默认最大值是1024
,因此只能监听0~1023
个文件描述符
poll
poll与select很接近,只是不采用bitsmap存储,改用了链表,解除了限制
但由于结构未变,因此poll与select都有遍历两次循环的问题,且都需要复制两次集合的操作
epoll
epoll
的诞生就是为了解决select/poll
留下的问题,且目前epoll
的应用场景也很多
关于存储文件描述符,epoll
使用红黑树来跟踪进程所有待检测的文件描述符,将需要监控的 Socket
使用 epoll_ctl()
函数将其加入至内核中的红黑树中,这样检查 Socket
是否有事件发生则只需要传入一个待检测的 Socket
即可,不需要整个集合复制,减少了多次数据拷贝的问题
epoll
使用事件驱动,内核中维护一个链表来记录就绪事件,当 Socket
发生了事件时,内核会将其加入这个链表中,用户调用 epoll_wait()
时,只返回链表(事件发生的个数)即可,不需要整个集合返回

水平触发
实则上面的 select/poll/epoll 都支持水平触发,当Socket
有可读事件发生时,服务端不断
地苏醒,直到read
函数执行完才停止
边缘触发
epoll独有的边缘触发,当 Socket
有可读事件发生时,服务端从 epoll_wait
中苏醒,并执行read
读取数据,由于他只苏醒一次的概念,因此我们在读取时要一次性将数据读完
边缘触发是循环从文件描述符
读写数据,那么如果文件描述符
是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 I/O 搭配使用,程序会一直执行 I/O 操作,直到系统调用(如 read 和 write)返回错误,错误类型为 EAGAIN 或 EWOULDBLOCK
结语
多路复用 API 返回的事件并不一定可读写的,因此使用多路复用时最好配合 非阻塞 I/O
一起使用,应对特殊情况