注意:本文章整理于GPT,仅用于复习。
1、讲一下select,poll , epoll区别?epoll实现机制,为什么返回的都是就绪的,为什么select不能做到?epoll水平触发和边缘触发?epoll的边缘触发,触发没有处理,后面怎么办(事件是不是都在队列不处理了?)?如果后面又处理这个事件,会丢掉吗还是一直都在队列里面了?
select
、poll
和 epoll
的区别
select
- 功能:监视多个文件描述符,等待其中的一个或多个变为可读、可写或有异常。
- 限制:受文件描述符数量的限制,通常为 1024。
- 效率:每次调用都需要重新设置文件描述符集合并线性扫描,效率较低。
- 返回机制:返回文件描述符集合,需要遍历集合找到就绪的文件描述符。
poll
- 功能:类似于
select
,但没有文件描述符数量限制。 - 限制:监视的文件描述符数量理论上没有限制,但受系统内存大小影响。
- 效率:每次调用都需要重新设置文件描述符集合并线性扫描,但相较
select
稍微高效。 - 返回机制:返回包含就绪状态的
pollfd
结构体数组,需要遍历数组找到就绪的文件描述符。
epoll
- 功能:高效地监视大量文件描述符,仅返回就绪的文件描述符。
- 限制:在 Linux 系统上,没有文件描述符数量限制。
- 效率:基于事件驱动,只有状态变化时才会触发,不需要线性扫描。
- 返回机制:直接返回就绪的文件描述符,避免了大量无效的检查。
epoll
的实现机制
epoll
通过内核空间维护一个红黑树来存储所有需要监视的文件描述符,以及一个双向链表来存储就绪的文件描述符。通过这种结构,epoll
可以高效地添加、删除和监视文件描述符。
- 红黑树:存储所有监视的文件描述符,支持快速增删操作。
- 就绪列表:存储就绪的文件描述符,当文件描述符状态发生变化时,会被加入到就绪列表中。
为什么 epoll
返回的都是就绪的?
epoll
只返回那些状态已经发生变化的文件描述符(即就绪的文件描述符),因为 epoll
内核实现中,会在文件描述符状态变化时,将其加入就绪列表,并在调用 epoll_wait
时,直接返回就绪列表中的文件描述符。因此,只返回就绪的文件描述符,避免了大量无效的检查和返回。
为什么 select
做不到?
select
每次调用都需要重新设置和扫描整个文件描述符集合,检查每个文件描述符的状态。由于 select
没有像 epoll
那样的事件驱动机制,因此每次都必须遍历所有监视的文件描述符,效率较低。
epoll
的水平触发(LT)和边缘触发(ET)
-
水平触发(Level Triggered, LT):
- 默认模式。
- 只要文件描述符处于就绪状态,
epoll_wait
会一直返回。 - 简单易用,但可能会导致重复触发。
-
边缘触发(Edge Triggered, ET):
- 需要显式指定。
- 只有在文件描述符状态发生变化时才会触发一次,之后即使文件描述符保持就绪,也不会再次触发,直到状态再次变化。
- 高效,但需要确保每次处理所有数据,避免丢失事件。
边缘触发未处理事件的处理
边缘触发,事件未处理,后续怎么办?
在边缘触发模式下,如果一个事件未处理完成(例如,数据未全部读取),后续不会再次触发同一事件,直到状态再次变化。因此,必须确保每次处理所有数据,避免丢失事件。
事件是否都在队列中不处理了?
未处理的事件不会继续留在就绪队列中,因为边缘触发模式下,只有状态变化才会触发事件。如果事件未完全处理,必须手动再次检查文件描述符状态(例如,通过非阻塞读取或写入)。
后续处理事件会丢失吗?
如果一个事件在边缘触发模式下未完全处理,那么当再次调用 epoll_wait
时,事件不会再次返回,除非文件描述符状态再次变化。因此,必须确保每次处理所有数据,以避免事件丢失。
示例代码
#include <sys/epoll.h>
#include <fcntl.h>
#include <unistd.h>
#include <iostream>
#include <vector>
int main() {
int epfd = epoll_create1(0);
if (epfd == -1) {
perror("epoll_create1");
return -1;
}
int fd = open("test.txt", O_RDONLY | O_NONBLOCK);
if (fd == -1) {
perror("open");
return -1;
}
epoll_event event;
event.events = EPOLLIN | EPOLLET; // 边缘触发
event.data.fd = fd;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event) == -1) {
perror("epoll_ctl");
return -1;
}
std::vector<epoll_event> events(10);
while (true) {
int n = epoll_wait(epfd, events.data(), events.size(), -1);
if (n == -1) {
perror("epoll_wait");
break;
}
for (int i = 0; i < n; ++i) {
if (events[i].events & EPOLLIN) {
char buf[1024];
ssize_t count;
while ((count = read(fd, buf, sizeof(buf))) > 0) {
// 处理读取的数据
std::cout.write(buf, count);
}
if (count == -1 && errno != EAGAIN) {
perror("read");
break;
}
}
}
}
close(fd);
close(epfd);
return 0;
}
在此代码中,边缘触发模式确保只有在文件描述符状态变化时才会触发事件。确保每次读取所有数据,避免事件丢失。
2、socket.accept()函数对应着TCP三次握手中哪一次握手,哪个函数代表连接建立完毕,四次挥手的过程?一台机器最多可以建立多少个tcp连接,client端,server端,超过了怎么办?socket编程服务端和客户端调用函数的流程?网络编程如果不用io多路复用该用什么?
TCP三次握手与四次挥手
TCP三次握手
- 第一次握手:客户端发送一个SYN包,表明希望建立连接,并且随机产生一个序列号。
- 第二次握手:服务器接收到SYN包后,回复一个SYN-ACK包,表示同意连接,并且自己也随机产生一个序列号。
- 第三次握手:客户端收到SYN-ACK包后,再回复一个ACK包,表示确认连接。
TCP四次挥手
- 第一次挥手:客户端发送一个FIN包,表示请求关闭连接。
- 第二次挥手:服务器接收到FIN包后,回复一个ACK包,表示同意关闭连接。
- 第三次挥手:服务器发送一个FIN包,表示服务器也请求关闭连接。
- 第四次挥手:客户端接收到FIN包后,回复一个ACK包,表示确认关闭连接。
socket.accept()
函数
- 对应握手:
socket.accept()
函数对应三次握手中的第三次握手。它在第三次握手完成后返回,表明连接已经建立。 - 连接建立完毕的函数:
socket.accept()
返回时,连接已经建立完毕。返回的值是一个新的套接字对象,用于与客户端进行通信。
TCP连接数限制
-
服务器端:理论上,一个服务器可以处理65535个并发连接(端口号范围)。但实际上,受限于系统资源(如内存、文件描述符数等),并发连接数会更少。可以通过调整系统参数(如
ulimit
)增加允许的文件描述符数。 -
客户端:每个客户端的单个源IP地址和端口号组合成一个唯一的套接字,每个IP地址可以有多个连接,但同样受限于端口号范围和系统资源。
-
超过连接数怎么办:
- 负载均衡:使用负载均衡器分担负载到多个服务器上。
- 水平扩展:增加更多的服务器来处理更多的连接。
- 优化资源使用:通过合理的资源管理和连接池技术,减少资源消耗。
Socket编程服务端和客户端调用函数流程
服务端流程
- 创建套接字:
socket()
- 绑定地址:
bind()
- 监听连接:
listen()
- 接受连接:
accept()
- 处理连接:读写数据,
recv()
和send()
或read()
和write()
- 关闭连接:
close()
客户端流程
- 创建套接字:
socket()
- 连接服务器:
connect()
- 通信:读写数据,
recv()
和send()
或read()
和write()
- 关闭连接:
close()
不用IO多路复用的替代方案
- 多线程:为每个连接创建一个线程,适合处理少量并发连接,但线程数目多时开销大。
- 多进程:为每个连接创建一个进程,适合在高隔离要求的场景使用,但进程切换开销大。
- 异步IO:使用非阻塞IO和事件通知机制(如Linux的
aio
,Windows的IOCP
),在单个线程内处理多个连接。 - 协程:使用轻量级线程(如Python的
asyncio
,Go语言的goroutine),提供类似多线程的编程模型,但资源消耗更低。
小结
socket.accept()
对应TCP三次握手的第三次握手。- 连接建立完毕的标志是
socket.accept()
返回。 - TCP四次挥手包含了两个FIN包和两个ACK包,确保双方都能优雅地关闭连接。
- 一台机器的TCP连接数受系统资源限制,通过负载均衡和优化资源管理可以扩展处理能力。
- 不使用IO多路复用时,可以采用多线程、多进程、异步IO或协程来处理并发连接。
3、介绍五大 IO 模型?reactor 和 proactor 的区别?为什么要使用Reactor模式, 为什么不直接用多线程?epoll_wait 是怎么处理的和time_out?如果客户端崩溃服务器会怎么样?Eventloop是怎么实现的?一个线程会发生死锁吗(比如:多次尝试同一个锁、递归获取锁)?
五大 IO 模型
1. 阻塞 I/O (Blocking I/O)
在阻塞 I/O 模型中,系统调用(如 read
或 recv
)会阻塞进程,直到数据就绪。这种模式简单易用,但效率低下,因为进程在等待数据时无法进行其他操作。
2. 非阻塞 I/O (Non-blocking I/O)
在非阻塞 I/O 模型中,系统调用不会阻塞进程,而是立即返回一个状态值。如果数据未就绪,进程可以继续执行其他操作,并在稍后再次检查数据状态。这种模式减少了阻塞时间,但需要不断轮询,增加了系统开销。
3. I/O 多路复用 (I/O Multiplexing)
I/O 多路复用使用系统调用(如 select
、poll
和 epoll
)来同时监视多个文件描述符。一旦有文件描述符变为就绪状态,进程可以对其进行 I/O 操作。这种模式适用于处理大量并发连接。
4. 信号驱动 I/O (Signal-driven I/O)
在信号驱动 I/O 模型中,当文件描述符变为就绪状态时,内核会发送一个信号给进程。进程在接收到信号后,可以进行 I/O 操作。这种模式减少了轮询开销,但信号处理的实现和管理较为复杂。
5. 异步 I/O (Asynchronous I/O)
在异步 I/O 模型中,进程发起 I/O 操作后,立即返回,内核在操作完成后通知进程。进程在等待期间可以继续执行其他操作。这种模式最为高效,但实现复杂,要求操作系统提供良好的支持。
Reactor 和 Proactor 的区别
Reactor 模式
- 原理:Reactor 模式由事件循环驱动,监听事件的发生(如 I/O 事件),并在事件发生时调用相应的事件处理器(回调函数)。
- 流程:
- 事件发生时,事件循环通知应用程序。
- 应用程序处理事件并执行 I/O 操作。
- 特点:I/O 操作由应用程序执行,适用于同步 I/O。
Proactor 模式
- 原理:Proactor 模式也是事件驱动,但 I/O 操作由操作系统或底层库完成,完成后通知应用程序。
- 流程:
- 应用程序发起 I/O 操作。
- 操作系统或底层库完成 I/O 操作后,通知应用程序。
- 应用程序处理已完成的操作。
- 特点:I/O 操作由操作系统完成,适用于异步 I/O。
为什么要使用 Reactor 模式,为什么不直接用多线程?
- 资源效率:Reactor 模式通过事件循环处理多个连接,避免了线程上下文切换和调度的开销,资源效率更高。
- 编程简洁性:Reactor 模式简化了并发编程,避免了多线程中的竞态条件和死锁问题。
- 可扩展性:Reactor 模式在高并发场景下具有更好的可扩展性,能够更高效地处理大量并发连接。
epoll_wait
是怎么处理的和 time_out
?
- 处理:
epoll_wait
在调用时,会检查内核中的事件队列。如果有事件就绪,立即返回就绪的事件列表;如果没有事件就绪,则阻塞等待,直到有事件发生或超时时间到。 time_out
:time_out
参数指定epoll_wait
阻塞等待的最长时间(以毫秒为单位)。如果time_out
为 -1,epoll_wait
会无限期阻塞;如果为 0,epoll_wait
会立即返回,即使没有事件就绪。
如果客户端崩溃服务器会怎么样?
- TCP连接:如果客户端崩溃,服务器端的 TCP 连接可能会检测到连接的关闭(通过
FIN
包或RST
包)。服务器可以通过处理recv
返回 0 或处理异常来检测到这一情况。 - 资源释放:服务器应当正确处理客户端断开连接的情况,释放相应的资源(如文件描述符和内存)。
EventLoop 是怎么实现的?
EventLoop 通常包含以下几个步骤:
- 初始化:创建并初始化事件循环对象(如
epoll
实例)。 - 注册事件:将需要监视的文件描述符和事件类型注册到事件循环中。
- 事件循环:循环等待事件发生(如调用
epoll_wait
)。 - 事件处理:事件发生时,调用相应的事件处理器(回调函数)。
- 清理资源:事件循环结束后,清理所有资源。
示例代码(简化版):
#include <sys/epoll.h>
#include <unistd.h>
#include <vector>
#include <functional>
#include <iostream>
class EventLoop {
public:
EventLoop() : epfd(epoll_create1(0)) {}
~EventLoop() {
close(epfd);
}
void addEvent(int fd, uint32_t events, std::function<void()> callback) {
epoll_event event;
event.data.fd = fd;
event.events = events;
callbacks[fd] = callback;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);
}
void run() {
std::vector<epoll_event> events(10);
while (true) {
int n = epoll_wait(epfd, events.data(), events.size(), -1);
for (int i = 0; i < n; ++i) {
int fd = events[i].data.fd;
if (callbacks.find(fd) != callbacks.end()) {
callbacks[fd]();
}
}
}
}
private:
int epfd;
std::unordered_map<int, std::function<void()>> callbacks;
};
int main() {
EventLoop loop;
loop.addEvent(STDIN_FILENO, EPOLLIN, []() {
char buf[1024];
ssize_t n = read(STDIN_FILENO, buf, sizeof(buf));
if (n > 0) {
std::cout << "Read: " << std::string(buf, n) << std::endl;
}
});
loop.run();
return 0;
}
一个线程会发生死锁吗?
一个线程可以在以下情况下发生死锁:
- 递归锁:如果同一个线程多次尝试获取同一个非递归锁,会导致死锁。
- 递归获取锁:如果使用递归锁(如
std::recursive_mutex
),允许同一个线程多次获取同一个锁,不会导致死锁。
示例代码(递归锁):
#include <mutex>
#include <iostream>
std::recursive_mutex mtx;
void recursiveFunction(int n) {
if (n <= 0) return;
mtx.lock();
std::cout << "Locked: " << n << std::endl;
recursiveFunction(n - 1);
mtx.unlock();
}
int main() {
recursiveFunction(5);
return 0;
}
总结
- 五大 IO 模型:阻塞 I/O、非阻塞 I/O、I/O 多路复用、信号驱动 I/O 和异步 I/O。
- Reactor 和 Proactor 的区别:Reactor 由应用程序处理 I/O 操作,Proactor 由操作系统处理 I/O 操作。
- 为什么使用 Reactor 模式:资源效率高,编程简洁,可扩展性好。
epoll_wait
的处理和time_out
:检查内核事件队列,根据time_out
参数决定阻塞等待时间。- 客户端崩溃的处理:服务器通过
recv
返回 0 或异常检测客户端断开,释放资源。 - EventLoop 实现:初始化、注册事件、事件循环和事件处理。
- 单线程死锁:递归锁允许同一个线程多次获取锁,避免死锁。
4、NIO、BIO、同步IO、异步IO,以及它们与IO多路复用的区别和联系?介绍一下字节序?网卡是干啥的,网卡收发数据是通过什么实现的? accept 这个用 ET 模式你怎么实现一次性建立完连接?我们写的服务能否拿到用户端的ip,为什么可以拿到?网络字节序是大端还是小端,本地字节序是大端还是小端?讲讲阻塞IO和非阻塞IO,讲讲有哪些常用实现?
NIO、BIO、同步 I/O、异步 I/O 及其与 I/O 多路复用的区别和联系
BIO(Blocking I/O)
- 特点:阻塞 I/O,在进行 I/O 操作时,调用线程会被阻塞,直到操作完成。
- 使用场景:适用于并发连接数较少的应用。
- 实现方式:传统的
read
和write
调用。
NIO(Non-blocking I/O)
- 特点:非阻塞 I/O,I/O 操作不会阻塞调用线程,而是立即返回状态。
- 使用场景:适用于并发连接数较多的应用。
- 实现方式:使用
select
、poll
、epoll
等 I/O 多路复用机制。
同步 I/O
- 特点:调用发起后,调用方等待 I/O 操作完成。线程会被阻塞,直至操作完成。
- 实现方式:传统的阻塞 I/O、使用 I/O 多路复用的非阻塞 I/O 都是同步 I/O。
异步 I/O
- 特点:调用发起后,调用方无需等待 I/O 操作完成,I/O 操作由操作系统或底层实现完成后通知调用方。
- 使用场景:适用于高性能、高并发应用。
- 实现方式:如 Linux 下的
aio
接口。
I/O 多路复用
- 特点:通过一个系统调用(如
select
、poll
、epoll
)同时监控多个文件描述符的 I/O 事件。 - 使用场景:适用于需要处理大量并发连接的场景。
- 实现方式:
select
、poll
、epoll
。
字节序
- 大端字节序(Big Endian):高字节存储在低地址。
- 小端字节序(Little Endian):低字节存储在低地址。
- 网络字节序:大端字节序,确保不同系统间的数据传输一致性。
网卡(Network Interface Card, NIC)
- 功能:提供计算机与网络之间的数据传输接口,实现数据的发送和接收。
- 收发数据的实现:网卡硬件通过 DMA(直接内存访问)技术将数据直接传输到系统内存,并通过中断通知操作系统。
在 ET 模式下实现一次性建立完连接
使用边缘触发(ET)模式下,accept
的实现需要注意以下几点:
- 非阻塞模式:将监听套接字设为非阻塞模式。
- 循环处理:在事件触发时,使用循环来处理所有就绪的连接请求,直到没有新的连接为止。
示例代码:
#include <sys/epoll.h>
#include <fcntl.h>
#include <unistd.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <iostream>
int setNonBlocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main() {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
setNonBlocking(listen_fd);
sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(8080);
bind(listen_fd, (sockaddr*)&server_addr, sizeof(server_addr));
listen(listen_fd, 5);
int epfd = epoll_create1(0);
epoll_event event;
event.events = EPOLLIN | EPOLLET;
event.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &event);
while (true) {
epoll_event events[10];
int nfds = epoll_wait(epfd, events, 10, -1);
for (int i = 0; i < nfds; ++i) {
if (events[i].data.fd == listen_fd) {
while (true) {
sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int conn_fd = accept(listen_fd, (sockaddr*)&client_addr, &client_len);
if (conn_fd < 0) {
break;
}
setNonBlocking(conn_fd);
// 处理新的连接...
}
}
}
}
close(listen_fd);
close(epfd);
return 0;
}
获取客户端 IP
- 方法:使用
getpeername
函数获取连接的对端地址。 - 原因:每个连接在建立后,内核会维护连接的相关信息,包括对端 IP 地址和端口。
示例代码:
sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
getpeername(conn_fd, (sockaddr*)&client_addr, &client_len);
char *client_ip = inet_ntoa(client_addr.sin_addr);
int client_port = ntohs(client_addr.sin_port);
std::cout << "Client IP: " << client_ip << ", Port: " << client_port << std::endl;
字节序
- 网络字节序:大端字节序。
- 本地字节序:取决于具体平台,Intel x86 架构是小端字节序。
阻塞 I/O 和非阻塞 I/O
阻塞 I/O
- 特点:调用 I/O 函数时,调用线程被阻塞,直到 I/O 操作完成。
- 实现:传统的
read
、write
调用。
非阻塞 I/O
- 特点:调用 I/O 函数时,如果操作无法立即完成,立即返回错误,调用线程继续执行其他操作。
- 实现:通过设置文件描述符的
O_NONBLOCK
标志。
常用实现
select
:水平触发,适用于小规模文件描述符集合。poll
:水平触发,无文件描述符数量限制,但效率低下。epoll
:水平触发和边缘触发,高效处理大规模文件描述符集合。
总结
- NIO、BIO:分别为非阻塞 I/O 和阻塞 I/O。
- 同步 I/O、异步 I/O:同步 I/O 会阻塞调用线程,异步 I/O 立即返回。
- I/O 多路复用:通过
select
、poll
、epoll
实现。 - 字节序:大端和小端字节序。
- 网卡功能:实现数据收发,通过 DMA 传输数据。
- ET 模式的
accept
:需要非阻塞模式和循环处理。 - 获取客户端 IP:通过
getpeername
函数。 - 网络字节序和本地字节序:网络字节序为大端,本地字节序取决于平台。