1. Linux中的5种I/O模型
- 等待数据准备好(
waiting for the data to be eady
) - 将数据从内核拷贝到进程中(
copying the data from the kernel to the process
)
- 如果是socket中的
read
操作,第一步通常等待数据从网络中到达,数据到达后会被复制到内核的接收缓冲区中;第二步,将内核接收缓冲区中的数据复制到应用进程缓冲区。 - 根据这两个阶段的不同表现,Linux中有5种I/O模型:
- 同步模式: 阻塞式I/O(
BIO
)、非阻塞式I/O(NIO
)、I/O多路复用、信号驱动式I/O(SIGIO
),共四种 - 异步模式: 只有异步I/O(
AIO
)
① 阻塞式I/O(BIO)
- 阻塞式I/O: 应用进程执行系统调用后被阻塞,直到数据准备好并从内核缓冲区复制到应用进程缓冲区才返回。
- 以socket的接收操作为例:
- 应用进程调用
recvfrom()
函数接收对方socket传来的数据, recvfrom()
函数可以产生一个系统调用。 - 内核收到这个系统调用后先等待数据准备好,然后将数据从内核复制到应用进程中并返回
OK
。 - 应用进程在这个过程中会阻塞,直到收到系统调用返回的
OK
后,才能继续执行后续代码。
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
socket
、pipe
、FIFO
、terminal
的I/O默认模式都是阻塞式I/O。- 阻塞式I/O的优点: 应用进程被阻塞时会放弃CPU时间,让其他进程执行。这样不会浪费CPU时间,使得CPU的利用率比较高。
② 非阻塞式I/O(NIO)
- 非阻塞式I/O: 应用进程执行系统调用后会立马收到返回值,有可能是错误码(
errno
),也有可能是OK。 - 虽然应用进程未被阻塞,但为了保证I/O操作的成功,应用进程需要不断主动的查询结果,即需要进行轮询(polling)。
- 以socket的接收操作为例:
- 应用进程调用
recvfrom()
函数接收对方socket发来的数据,recvfrom()
函数会产生系统调用。 - 如果数据未准备好,系统调用直接返回
EWOULDLOCK
或者EAGAIN
错误码。 - 应用进程会进行轮询,直到某次系统调用达到时,数据已经准备好,内核将数据复制到应用进程缓冲区并返回
OK
。 - 应用进程收到系统调用返回的
OK
后,可以停止轮询,继续执行其他代码。
- 非阻塞式I/O的缺点: 由于应用进程使用轮询的方式确保I/O操作的成功,它会占用CPU时间导致CPU利用率不高。
③ I/O多路复用
- 首先,应用进程调用
select()
或者poll()
或者epoll()
函数监听多个socket是否可读(数据是否准备好)。 - 这一过程应用进程会被阻塞,直到某个socket变得可读。
- 接着,应用进程调用**recvfrom()**发起系统调用,然后内核将数据复制到应用进程中并返回
OK
。
- I/O多路复用可以使单个进程具有处理多个I/O事件的能力,又被称为事件驱动I/O(
Event Driven I/O
)。 - I/O多路复用的优点: socket连接数量大的系统中,I/O多路复用可以使系统开销更小。
- 如果一个web服务器没有使用I/O多路复用,那么对于每个socket连接都需要创建一个线程去处理。
- 如果同时有几万个socket连接,那么就需要创建相同数目的线程。
- 相比于多进程和多线程技术,使用I/O多路复用管理多个socket连接,可以减少进程、线程创建和切换的开销。
④ 信号驱动I/O
- 应用进程通过
sigaction
系统调用,向系统注册一个处理SIGIO的信号处理程序(signal handler
)。 - 该系统调用会立即返回,然后应用进程可以继续执行。
- 当数据准备好后,内核会向
signal handler
发送SIGIO信号。 signal handler
收到该信号后,会调用recvfrom()
函数发起系统调用,然后内核将数据复制到应用进程中并返回OK
。
- 信号驱动I/O模型中,应用进程在数据的准备阶段是非阻塞的,在数据的复制阶段是阻塞的,。
- 相比于通过轮询的非阻塞I/O,信号驱动I/O的CPU利用率更高。
⑤ 异步I/O(AIO)
- 应用进程调用
aio_read()
函数开启异步读操作,该函数会产生一个系统调用并立即返回。 - 应用进程可以继续执行,而不会被阻塞。
- 当数据准备就绪并从内核复制到应用进程中后,内核会向应用进程发送一个信号。
- 异步I/O中的信号是通知应用进程I/O操作已经完成,信号驱动I/O中的信号是通知应用进程可以开始执行I/O操作。
- 之前的4种I/O模型都是同步模型,数据的接收都需要自己调用
recvfrom()
函数将数据从内核复制到应用进程中。 - 异步I/O模型无需调用
recvfrom()
函数,操作系统会自动将数据从内核复制到应用进程中,并通知应用进程I/O操作执行完毕。
⑥ 5种模型的比较
- 之前的4种I/O模型都是同步模型,将数据从内核复制到应用进程中时,需要应用进程自己调用
recvfrom()
函数。 - 将数据从内核缓冲区复制到应用进程缓冲区的阶段,调用了
recvfrom()
函数,应用进程会阻塞。 - 同步模型中,不同I/O之间的区别主要在第一阶段。
- 只有AIO属于异步模型,将数据从内核复制到应用进程中时,应用进程无需调用
recvfrom()
函数。操作系统会自动完成数据的复制工作,并在完成后通知应用进程。 - 数据从内核缓冲区复制到应用进程缓冲区的阶段,应用进程不会阻塞。
2. socket
① socket中的同步
- 应用进程自己调用
recvfrom()
函数,将数据从内核缓冲区复制到应用进程缓冲区。 - 同步有两种情况:
- 阻塞: 妈妈让烧水,几岁的时候胆小怕出问题,会一直守着直到水烧开。
- 非阻塞: 妈妈让烧水,10来岁的时候迷上了偶像剧,烧上水后回房间看剧,一会出来看一下,没烧开又回去看剧。如此反复查看水是否烧开,直到最后水烧开。
② socket中的异步
- 应用进程调用
aio_read()
函数后立即返回,内核完成数据等待、数据复制后后,向应用进程发送信号,通知应用进程数据已经复制完毕。 - 妈妈让烧水,15岁以后烧水壶变先进了,水烧开后会发出声音。于是烧上水就可以安心的会房间看剧了,直到听到水开的声音,出来将茶叶泡上就好。
③ socket中tcp方式如何通过accpet实现连接?
- 第一个参数
sockfd
:将socket设置为被动模式,指示内核可以接受指向该套接字的连接请求。 - 第二个参数
backlog
:指示内核为该socket排队的最大连接个数,包括已完成连接队列、未完成连接队列。 - 设置成功返回0,失败返回-1
int listen(int sockfd, int backlog);
- 已完成三次握手的连接,即处于
ESTABLISHED
状态的连接,将进入已完成连接队列。 - 未完成三次握手的连接,即处于
SYN_RCVD
状态的连接,将进入未完成连接队列。 - 当应用进程调用
accept()
函数时,如果已完成连接队列不为空,则该队列中的头节点将返回给应用进程;否则,应用进程将会阻塞,直到已完成队列中有新的连接加入才会被唤醒。 - 已完成队列和未完成队列之和不超过
backlog
。SYN分节到达时,如果队列已满,TCP将会忽略该分节,等待客户超时重传。之所以这么做,因为这么情况是暂时的,客户在下一次重传时,队列很可能已存在可用空间。 - 如果服务器针对上面的情况立即响应
RST
(直接判死刑),客户的connect()
函数调用就会立即返回一个错误,强制应用进程处理这种错误。而且客户也无法判断RST
是由于队列已满,还是由于连接请求中的端口没有服务器在监听。
- 不要讲
backlog
设置为0,因为不同的实现对此有不同的解释。如果只是想让客户连接到监听套接字,直接close监听套接字即可。 backlog
的设置可以通过#define
进行宏定义,方便更改backlog
值时,只需要改动一个地方即可;还可以通过读取环境变量的值来设置backlog
的值。
④ socket中的tcp三次握手
- socket中连接的创建、通信、释放,过程示意图如下:
- socket中TCP的三次握手:
- 客户调用
connect()
函数触发连接请求,向服务器发送SYN J
包,对应的应用进程进入阻塞状态。 - 服务器调用
listen()
函数后开始监听连接请求,并调用accept()
函数对连接请求做出响应,对应的应用进程进入阻塞状态。服务器收到SYN J
包后,accpet()
函数做出响应,向客户发送SYN K, ACK J+1
包。 - 客户收到服务器的响应后(即收到
SYN K, ACK J+1
包后),connect()
函数返回,并向服务器发送ACK K+1
包进行确认。服务器收到ACK K+1
包后,accept()
函数返回。
accept()
函数和listen()
函数的先后关系:
accept()
函数是从listen()
函数维护的队列中中获取已完成三次握手的连接,没有就会阻塞。- 如果没有调用
accept()
函数,最后连接队列会爆满,导致新的连接请求对应的SYN分节会被忽略,或者服务器直接返回RST。 - 如果SYN分节被忽略,则客户会超时重传连接请求,遇上连接队列不满时,
connect()
函数执行成功返回0。 - 如果服务器直接返回RST,则
connect()
函数执行失败返回-1。 connet()
函数与accepet()
函数的调用,二者没有必然的先后关系。只是如果没有调用accept()
函数,会影响connect()
函数的结果。
⑤ socket中TCP的四次挥手
- 主机1上的应用进程调用
close()
函数将socket标记为关闭状态,并向主机2发送 FIN M
释放报文。这时,主机1进入FIN-WAIT1
状态。 - 主机2收到
FIN M
释放报文后,返回ACK M+1
报文进行确认,自己进入CLOSE_WAIT
状态。主机1收到确认后,进入FIN-WAIT2
状态。这时主机1不能再向主机2发送数据。 - 主机2上的应用进程调用close()函数将socket标记为关闭状态,并向主机1发送FIN N释放报文。这时,主机2进入LAST-ACK状态。
- 主机1收到
FIN N
释放报文后,返回ACK N+1
进行确认,自己进入TIME-WAIT
状态,等待至少2MSL
后,进入CLOSED
状态。主机2收到确认后,进入CLOSED
状态。
- 注意:
close()
函数将socket标记为关闭状态时,会让对应socket描述符的引用计数减1,直到引用计数为0,才会触发四次挥手。 - 关于
shutdown()
函数:
int shutdown(int sockfd, int howto)
shutdown()
函数的howto
参数,可以指定关闭某个方向的数据传送。
- SHUT_RD: 关闭连接的读这一半,socket接收缓冲区的现有数据都被丢弃,应用进程不能再对socket调用任何的读函数。如果一个socket关闭读连接后,收到其他socket传来的数据都会确认,然后悄然丢弃。
- SHUT_WR: 关闭连接的写这一半,socket发送缓冲区中的数据将被发送掉,应用进程不能再对socket调用任何写函数。
- SHUT_RDWR: socket连接的读和写都被关闭,等价于先后调用两次
shutdown()
函数,并指定howto
参数为SHUT_RD
、SHUT_WR
。
- 关闭socket的时机不同:
close()
函数让socket的引用计数减1,直到为0才能触发TCP的四次挥手;shutdown()
函数不管引用计数为多少,都能触发TCP的四次挥手。 - 全关闭与半关闭: close终止读和写两个方向的数据传送,shutdown一次可以只关闭一个方向的数据传送。