0、引言
本文主要讲述Socket网络编程的基本知识、IO多路复用的select、poll、epoll实现原理以及比较,并解答了一些socket建立连接、阻塞的常见问题。
1、什么是Socket、网络通信的过程
Socket 的中文名叫作插口,事实上,双方要进行网络通信前,各自得创建一个 Socket,这相当于客户端和服务器都开了一个“口子”,双方读取和发送数据的时候,都通过这个“口子”。
创建 Socket 的时候,可以指定网络层使用的是 IPv4 还是 IPv6,传输层使用的是 TCP 还是 UDP。UDP 的 Socket 编程相对简单些,这里我们只介绍基于 TCP 的 Socket 编程。
服务器的程序要先跑起来,然后等待客户端的连接和数据,我们先来看看服务端的 Socket 编程过程:
- 服务端调用
socket()
函数:指定网络协议IPv4、传输层协议TCP(告诉通信方怎么传输) - 调用
bind()
函数绑定,给这个 Socket 绑定一个 IP 地址和端口(告诉通信方往哪里传数据)
注:一台机器有多个网卡,每个网卡有自己的IP地址;每个程序有自己的端口号; - 调用
listen()
函数进行监听(监听的含义就是,看有没有客户端来发起连接) - 服务端进入了监听状态后,通过调用
accept()
函数,来从内核获取客户端的连接,如果没有客户端连接,则会阻塞等待客户端连接的到来。
那客户端是怎么发起连接的呢?
客户端在创建好 Socket 后,调用 connect()
函数发起连接,该函数的参数要指明服务端的 IP 地址和端口号,然后万众期待的 TCP 三次握手就开始了。
连接建立后,客户端和服务端就开始相互传输数据了,双方都可以通过 read()
和 write()
函数来读写数据。
特别注意:
当服务端 accept 接收一个客户端的连接请求后,它会为该连接创建一个新的 socket,并使用该 socket 来与该客户端进行通信。这个新的 socket 通常称为已连接套接字(Connected Socket),也称为通信套接字(Communication Socket)。在 TCP Socket 中,每个已连接的套接字都代表了一个客户端和服务端之间的一条连接。
也就是说,监听套接字一般只有一个,而连接套接字可能有多个。
在服务器处理一个客户端的数据请求时,其他客户端连接的已连接套接字并不会被阻塞或关闭,它们仍然可以发送请求,只不过服务器在阻塞模式下处理多个请求时会发生阻塞,只能一个一个处理。
而在已连接的 socket 进行数据传输时,监听 socket 仍然处于监听状态,可以接受新的连接请求,并创建新的 socket 连接。
对此,需要采用IO多路复用的模式来解决串行处理效率低的问题
2、IO多路复用
在阻塞式 I/O 模式下,一个已连接套接字在进行读写操作时会阻塞当前线程(或进程),直到操作完成或超时为止。因此,当服务器接受多个客户端连接并同时监听多个已连接套接字时,在处理某个连接请求时,如果该连接未响应,当前线程就会一直被阻塞,直到该请求完成或超时。这样的处理方式会导致服务器无法同时响应多个其他客户端的请求, 而非阻塞IO模式又会导致cpu空转,效率较低。
因此在实际应用中通常会使用多路复用 I/O 等技术来解决这个问题。服务器不会阻塞在某个连接上,而是可以同时监听多个已连接套接字,并根据事件类型进行相应的处理,以提高并发性能和吞吐量。
2.1 select/poll的方式实现多路复用
select 实现多路复用的方式是:
将已连接的 Socket 都放到一个FD文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写(0和1来表示), 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。
所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。
select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024
,只能监听 0~1023 的文件描述符。
poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。
但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。
2.2 epoll实现
epoll 通过两个方面,很好解决了 select/poll 的问题。
第一点,epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删查一般时间复杂度是 O(logn),通过对这棵黑红树进行操作,这样就不需要像 select/poll 每次操作时都传入整个 socket 集合,只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
第二点, epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。
网络模型-epoll中的ET和LT
当FD有数据可读时,我们调用epoll_wait(或者select、poll)可以得到通知。但是事件通知的模式有两种:
LevelTriggered:简称LT,也叫做水平触发。只要某个FD中有数据可读,每次调用epoll_wait都会得到通知。
EdgeTriggered:简称ET,也叫做边沿触发。只有在某个FD有状态变化时,调用epoll_wait才会被通知。
一般而言,边缘触发的方式会比水平触发的效率高。