本文目录
一、编写一个简单Server的步骤?
以TCP Server
为例子,来看看具体的核心。
首先调用serverfd=Socket(opt)
创建一个对应的serverfd
,然后调用Bind()
方法将fd和指定的地址(ip+port)
进行绑定,也就是Bind(serverfd,address)
,然后调用Listen()
进行对应的监听,监听前面绑定的地址。最后是进入循环等待客户端的连接请求clientfd=Accept(serverfd)
,后续所有的客户端的读写都是基于这个clientfd进行操作的。
建立连接之后,server处理client请求的过程如下:从clientfd
中读取传输进来的数据,并将数据存放到buf中,然后往客户端写出n个字节的数据。
二、Tcp中Server和Client完整交互的过程
(下图源自Unix网络编程第三版。)
首先,TCP服务器通过调用socket()
创建一个套接字,然后使用bind()
将其绑定到一个端口上。接着,服务器调用listen()
开始监听来自客户端的连接请求,并通过accept()
阻塞等待客户端的连接。一旦客户端通过connect()发起连接请求,服务器和客户端之间就会建立连接(通过TCP三次握手)
。
在连接建立后,客户端使用write()发送数据请求到服务器,服务器通过read()接收这些数据并进行处理
。处理完成后,服务器通过write()将响应数据发送回客户端。客户端通过read()接收服务器的响应数据。最后,当通信结束时,客户端和服务器都会调用close()来关闭各自的套接字,完成连接的终止。
对系统上层暴露的是系统调用的接口,其他的都封装在操作系统内核里边。其他还涉及一些中断、内存等信息。
三、网络演变的本质
随着网络连接数量的增加,网络就开始演变了。
在linux0.96的版本,开始支持网络。然后慢慢的不断进行迭代。
四、阻塞IO(Block IO)
4.1 什么是BIO?
应用程序通过调用recvfrom
函数发起一个系统调用,请求从网络接收数据。
内核接收到系统调用后,首先检查是否有数据报文(datagram)准备好。如果没有数据报文准备好,内核会等待数据的到来。在等待数据报文的过程中,应用程序进程会被阻塞,即进程会挂起,直到数据报文到达。
当数据报文到达时,内核会将数据报文从内核缓冲区复制到用户空间的缓冲区中。数据复制完成后,内核会向应用程序返回一个成功的状态(return OK),表示数据接收成功。
应用程序接收到内核返回的成功状态后,会继续执行并处理接收到的数据报文。
上述过程有两个阻塞,分别是内核在等待数据的时候,还有就是从内核态拷贝数据到用户态的时候。
现代操作系统中,内核负责管理所有的硬件设备,包括网络接口卡(NIC,即网卡)。当数据从网络传输到计算机时,数据首先到达网卡,然后通过设备驱动程序将数据传递给内核。
内核接收到数据包后,将其存储在内核缓冲区中。内核缓冲区是操作系统内核的一部分,用于临时存储从网络接收到的数据。
阻塞IO的优点就是实现比较简单,通常一个client连接分配一个线程(server端)进行处理就够了,可以实现client和server端的通信。
但是缺点很明显,就是支持的并发client连接数很少,因为server能够分配的线程是有限的,大量的线程会造成上下文切换过多从而影响性能。
过多的线程还会消耗大量的内存资源,因为每个线程都需要有自己的堆栈空间。在高并发场景下,这种资源消耗可能会迅速增长,导致系统资源耗尽,进而影响系统的稳定性和可靠性。因此,对于需要处理大量并发连接的应用,阻塞I/O模型可能不是最佳选择,而应该考虑使用非阻塞I/O、事件驱动模型或异步I/O等更高效的并发处理机制
。
4.2 如何改进BIO?(NIO)
之所以一个client连接分配一个线程是因为处理客户端的读写方式是阻塞式的,为了避免这个阻塞影响后续client连接,所以需要将阻塞逻辑交由单独线程处理。
改进BIO,就引出了非阻塞IO
。这一阶段的改进需要内核来支持。
应用程序通过调用recvfrom
函数发起一个系统调用,请求从网络接收数据。
内核接收到系统调用后,首先检查是否有数据报文(datagram)准备好。如果没有数据报文准备好,内核会返回一个错误码EWOULDBLOCK
,表示当前没有数据可读。
应用程序接收到EWOULDBLOCK错误码后,知道当前没有数据可读,因此不会阻塞等待数据。应用程序可以选择立即再次发起recvfrom系统调用,或者执行其他任务后再发起调用。
应用程序会重复调用recvfrom函数,等待内核返回一个成功的结果。这种重复调用的过程称为轮询(polling)
,应用程序通过不断轮询内核来检查数据是否到达。
当数据报文到达时,内核会将数据报文从内核缓冲区复制到用户空间的缓冲区中。数据复制完成后,内核会向应用程序返回一个成功的状态(return OK),表示数据接收成功。
五、BIO和NIO的对比
阻塞IO和非阻塞IO的主要区别在于内核中数据尚未就绪时,如何处理。
对于非阻塞IO,则直接返回给用户态EWOULDBLOCK错误。而阻塞IO则一直处于阻塞状态,直到数据就绪并从内核态拷贝到用户态后才返回。
在阻塞I/O(Blocking I/O)中,"阻塞"是指用户空间的应用程序被阻塞。当应用程序发起一个阻塞I/O操作时,比如调用recvfrom函数来接收数据,如果数据尚未准备好,那么这个调用会使得应用程序进入阻塞状态,直到以下两个条件之一发生:1、数据已经到达并且可以被读取。2、出现了一个错误或者调用被其他方式中断(比如超时或信号)。
在阻塞期间,应用程序无法继续执行其他任务,它会被挂起,直到I/O操作完成。这段时间内,操作系统内核会继续处理其他任务,包括可能的其他I/O操作和进程调度。
对于非阻塞I/O(Non-blocking I/O),当应用程序发起一个非阻塞I/O操作时,如果数据尚未准备好,内核不会阻塞应用程序进程,而是立即返回一个错误码(如EWOULDBLOCK),告知应用程序当前操作不能完成。应用程序需要定期重新发起I/O操作,直到数据准备好为止,这个过程称为轮询(polling)。
阻塞I/O中的阻塞是指用户空间的应用程序被阻塞,而不是内核被阻塞。内核在阻塞I/O操作期间仍然可以处理其他任务,而应用程序则需要等待I/O操作完成才能继续执行。
在整个客户端-服务器通信框架中,NIO模型可以被应用于任何一方或双方。例如,服务器可以使用NIO来处理多个客户端连接,而客户端也可以使用NIO来处理与服务器的通信以及可能的其他I/O操作
。
5.1 如何设置非阻塞
可以通过socket()
方法中的type参数来制定为SOCK_NONBOLOCK
,即可设置该socket为非阻塞方式。
通过fcntl()
方法中args参数为O_NONBLOCK
就可以设置了。
如fcntl(socket_fd, F_SETFL, flags | O_NONBOLOCK)
。
5.2 非阻塞IO的问题
NIO的优点是:将socket设置成非阻塞后,在读取时如果数据未就绪就直接返回。得益于非阻塞的特性可以通过一个线程管理多个client连接。
缺点就是需要不断的轮询询问内核,数据是否就绪,涉及太多无效、频繁的系统调用了。
六、IO多路复用
NIO也有缺点,针对这些缺点,就有了IO多路复用。
select是I/O多路复用最早和最广泛使用的系统调用之一。它通过监视一组文件描述符,来检查这些描述符是否有数据可读、可写或是否有异常发生。select函数使用三个文件描述符集合(读、写、异常)和一个可选的超时参数,当有文件描述符就绪时,select返回就绪描述符的数量,从而让应用程序可以进行相应的I/O操作。然而,select存在一些限制,如文件描述符数量的限制(通常由FD_SETSIZE定义,一般为1024),以及在处理大量文件描述符时的性能问题。
poll是另一种实现I/O多路复用的系统调用,它提供了与select类似的功能,但采用了不同的实现方式。poll通过维护一个动态的文件描述符列表来工作,每个文件描述符都对应一个pollfd结构。与select相比,poll没有文件描述符数量的限制,因为它不使用位图来表示文件描述符集合,从而可以支持更多的并发连接。但是,poll在处理大量活跃连接时可能会有较高的内存消耗,并且每次调用都需要传递整个文件描述符列表,这可能导致较高的CPU开销。但poll仍然是一个有用的工具,特别是在需要处理大量文件描述符的场景下。
6.1 IO多路复用到底复用了什么?
- I0多路复用主要复用的是系统调用。从原先非阻塞情况下的多个client需要各自多次发送recvfrom系统调用去不断询问内核数据是否已就绪;转变成了现在通过一次系统调用select/poll由内核主动通知用户哪些client数据已就绪(read、write、accept等事件)。大大减少了无效的系统调用次数。
在非阻塞I/O模型中,每个客户端连接都需要通过recvfrom系统调用不断轮询内核,检查数据是否就绪。这种方式会导致大量的系统调用,效率较低。在I/O多路复用模型中,通过select/poll/epoll等系统调用,一次系统调用可以监控多个客户端连接,内核会主动通知用户哪些连接的数据已经就绪(例如可读、可写或可接受新连接)。这种方式减少了无效的系统调用次数,提高了效率。
- IO多路复用就是复用一个线程,多个客户端都需要一个线程去调用recvfrom阻塞等待内核数据返回,那么多个客户端就需要多个线程,现在多个客户端都用一个线程使用select去统一管理,所以复用了这个线程。
在传统的阻塞I/O模型中,每个客户端连接都需要一个独立的线程调用recvfrom阻塞等待数据返回。这种方式在高并发场景下会导致线程数量过多,资源消耗巨大。
在I/O多路复用模型中,一个线程可以通过select/poll/epoll管理多个客户端连接,而不需要为每个连接创建单独的线程。这种方式复用了线程,减少了线程数量,提高了并发性能。I/O多路复用的另一个核心优势就是通过一个线程管理多个连接,减少了线程的创建和切换开销。
6.2 具体模型说明
假设我们正在开发一个聊天服务器,该服务器需要能够同时处理多个客户端的连接。每个客户端都可以发送消息给服务器,服务器再将这些消息转发给其他所有连接的客户端。
在非阻塞I/O模型中,对于每个客户端连接,服务器都需要不断地调用recvfrom系统调用来检查是否有新的消息到达。由于是非阻塞的,如果没有消息到达,recvfrom会立即返回一个错误码(如EWOULDBLOCK),服务器需要再次调用recvfrom来检查。这会导致大量的无效系统调用,因为大多数调用可能都不会读取到任何数据。
使用I/O多路复用(如select或poll)的聊天服务器,可以通过一次系统调用来监控多个客户端连接。当任何一个客户端有数据可读时,select或poll会通知服务器。这样,服务器只需要对那些真正有数据可读的客户端调用recvfrom,从而大大减少了无效的系统调用次数。
6.3 select核心接口
select() 函数是Unix和类Unix操作系统中用于I/O多路复用的核心接口,它允许单个线程监视多个文件描述符(file descriptors,fd),以确定它们是否有数据可读、可写或是否出错。以下是select()函数的核心接口说明:
int select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout);
maxfdp1:表示被select管理的描述符个数。其值为最大描述符+1,而不是最大描述符值。select() 会检查从0到maxfdp1 - 1的所有文件描述符。(p1是plus的缩写)。通过这个参数可以提升性能。
readset:指向一个文件描述符集合的指针,该集合中包含应用程序想要监视是否有数据可读的文件描述符。
writeset:指向一个文件描述符集合的指针,该集合中包含应用程序想要监视是否可写的文件描述符。
exceptset:指向一个文件描述符集合的指针,该集合中包含应用程序想要监视是否有异常发生的文件描述符。
timeout:指向一个struct timeval结构的指针,该结构指定了select()函数等待I/O事件的最长时间。如果设置为NULL,则表示无限等待。
maxfdp1参数指定了要测试的最大描述符值加1。例如,如果进程只对描述符1、4和5感兴趣,那么maxfdp1的值应该是6(因为描述符从0开始计数)。通过指定maxfdp1,进程告诉内核它只关心从0到maxfdp1-1的描述符,内核就不需要检查其他描述符了。内核通过不复制描述符集合中不需要的部分,减少了进程和内核之间的数据传输,从而节省了时间和资源。此外,内核还可以跳过那些始终为0的位的测试,进一步减少了不必要的计算。通过避免不必要的复制和测试,内核可以减少对内存和CPU资源的消耗,从而提高整体系统的性能和效率。
timeout时间有三种含义:永远等待NULL(小于0)、正常超时(大于0)、立即返回(0)。
select的核心底层实现就是fd_set
。将要管理的描述符用位数组
的方式进行管理,用int类型的数组,用每一位表示一个客户端描述符。通常将fd_set中的每个文件描述符称为一个“位”(bit)。例如,当我们说“在读取集合中打开监听描述符的位”时,意味着将对应文件描述符的位置设置为1。
这就会涉及一些操作,就是把fd加入或者查询、删除清0到这个set中,下面是功能的定义。
比如定义一个fd_set rest
,然后通过上面的函数,把1、4、5这几个文件描述符加入到set里面。
6.4 poll核心接口
poll是另一种实现I/O多路复用的系统调用,它提供了与select类似的功能,但采用了不同的实现方式。poll通过维护一个动态的文件描述符列表来工作,每个文件描述符都对应一个pollfd结构。与select相比,poll没有文件描述符数量的限制,因为它不使用位图来表示文件描述符集合,从而可以支持更多的并发连接。但是,poll在处理大量活跃连接时可能会有较高的内存消耗,并且每次调用都需要传递整个文件描述符列表,这可能导致较高的CPU开销。
#include <poll.h>
int poll(struct pollfd *fdarray, unsigned long nfds, int timeout);
fdarray:指向pollfd结构数组的指针,该数组包含了需要检查的文件描述符及其相关事件。数组中的每一个元素是一个pollfd结构体对象,关联一个管理的描述符fd。
nfds:fdarray数组中pollfd结构的数量。表示管理的描述符个数。主要原因是前面的fdarray是一个可变长度的数组,因需要制定数组长度。
timeout:等待事件的最长时间(以毫秒为单位)。如果设置为-1,则无限期等待;如果设置为0,则立即返回,不等待。大于0是等待时间。
返回就绪的文件描述符的数量。如果超时,则返回0。如果发生错误,则返回-1,并设置errno以指示错误原因。
struct pollfd {
int fd; /* descriptor to check */
short events; /* events of interest on fd */
short revents;/* events that occurred on fd */
};
fd:要检查的文件描述符。
events:对该文件描述符感兴趣的事件类型,可以是以下事件的组合:
POLLIN:可读事件(数据可读)。
POLLOUT:可写事件(可以写数据)。
POLLERR:错误事件。
POLLHUP:挂起事件(连接已关闭)。
POLLNVAL:无效文件描述符。
revents:实际发生的事件,由poll()函数填充。可能的值与events相同。
6.5 select和poll区别
缺点很明显,就是每次select或者poll都需要将注册管理的多个client从用户态拷贝到内核态,在管理百万级别连接时候,由拷贝带来的资源开销太大,影响性能。同时还需要用户态去判断哪些描述符已经就绪了。
本文为听B站视频https://www.bilibili.com/video/BV12U4y167sf?spm_id_from=333.788.videopod.episodes&vd_source=c3b59da14262b87b7af597ccac1140a4&p=2
所做笔记,文中部分图源自视频。