310
社区成员




这个作业属于哪个课程 | 软件工程实践-2023学年-W班 |
---|---|
这个作业要求在哪里 | 软件工程实践总结&个人技术博客 |
这个作业的目标 | 实践总结&个人技术 |
其他参考文献 | 《构建之法》 |
目录
在正式的代码编写工作展开前,我就一直致力于c++的I/O多路复用学习。I/O多路复用是一种允许单个线程处理多个I/O操作的技术。在C++中,可以使用select,poll,或者更高效的epoll来实现I/O多路复用。
无论使用select、poll还是epoll,都需要绑定端口并开启监听
在下面这个类的构造函数中完成了bind和listen动作
// bind & listen
ServerListen::ServerListen()
{
serverListenSkt = socket(AF_INET, SOCK_STREAM, 0);
if (serverListenSkt == -1)
{
perror("params of socket might be wrong,pls check");
close(serverListenSkt);
return;
}
struct sockaddr_in sktaddr;
memset(&sktaddr, 0, sizeof(sktaddr));
sktaddr.sin_family = AF_INET;
sktaddr.sin_port = htons(50001);
sktaddr.sin_addr.s_addr = inet_addr("10.133.69.143");
int optval = 1;
setsockopt(serverListenSkt, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));
if (-1 == bind(serverListenSkt, (struct sockaddr *)&sktaddr, sizeof(sktaddr)))
{
perror("bind error!");
close(serverListenSkt);
return;
}
if (-1 == listen(serverListenSkt, 5))
{
perror("listen error!");
close(serverListenSkt);
return;
}
}
在上述代码中,struct sockaddr_in 是一个用于存储IPv4地址和端口号的结构体。它包含了以下成员变量:
sin_family
:表示地址族,对于IPv4地址,它的值应该是AF_INET
。
sin_port
:表示端口号,使用htons
函数将端口号从主机字节序转换为网络字节序。
sin_addr.s_addr
:表示IPv4地址,使用inet_addr
函数将点分十进制的IP地址转换为网络字节序的二进制形式。
struct sockaddr 是一个通用的套接字地址结构体,用于存储任意类型的套接字地址。它是一个抽象的结构体,用于在不同的协议族中传递套接字地址信息。在实际使用中,通常会将其转换为具体的地址结构体,如struct sockaddr_in
。
这些结构体的作用是在网络编程中进行套接字操作时,用于存储和传递地址信息。在代码中,struct sockaddr_in
用于存储服务器的地址信息,而 struct sockaddr 则用于在 bind
函数中传递地址信息。
select
是一种I/O多路复用的技术,它允许程序监视多个(一般来说,最多1024个)文件描述符(通常是套接字),以查看是否有数据可读、可写或是否有异常条件发生。当某个文件描述符就绪(即满足监视条件)时,select
函数就会返回。
下面的代码展示了如何使用SELECT来处理多个客户端的请求
void ServerListen::acceptWithSelect()
{
fd_set fds;
FD_ZERO(&fds);
FD_SET(serverListenSkt, &fds);
int maxfd = serverListenSkt;
while (true)
{
fd_set tmp = fds;
select(maxfd + 1, &tmp, nullptr, nullptr, nullptr); // 返回就绪文件描述符个数
for (int i = 0; i <= maxfd; ++i)
{
if (FD_ISSET(i, &tmp))
{
if (i == serverListenSkt)
{
int clientConn = accept(i, 0, 0);
FD_SET(clientConn, &fds);
maxfd = clientConn > maxfd ? clientConn : maxfd;
}
else
{
int ret = recvAndSend(i);
if (ret == -1)
FD_CLR(i, &fds);
else if (ret == -3)
goto shutdown;
}
}
}
}
shutdown:
for (int i = 0; i <= maxfd; ++i)
{
close(i);
}
}
从上面的代码可以看出,使用SELECT每次都需要遍历所有的文件描述符。如果发现某个文件描述符上有更新且该文件描述符是服务器监听的socket,则表示有一个新的客户端连接请求,此时需要我们为该连接创建一个新的文件描述符用于接收来自客户端的数据并传回相应的数据。
SELECT是跨平台的,然而其性能却不如poll和epoll。为了使部署在Linux上的服务器发挥更好的性能,我继续往下学习poll和epoll。
poll的地位比较尴尬。因为他无法跨平台,底层是数组,性能又不如epoll,因此使用的比较少。
与select
函数相比,poll
函数没有最大文件描述符数量的限制,因此可以处理更多的文件描述符。此外,poll
函数的参数是一个数组,而不是位图,因此在处理大量文件描述符时,poll
函数的效率可能会更高。
poll的使用方法与select大致相同,下面的代码对poll的使用进行了简单的演示
void ServerListen::acceptWithPoll()
{
struct pollfd fds[1024];
for (int i = 0; i < 1024; ++i)
{
fds[i].fd = -1; // 文件描述符,-1为无效值
fds[i].events = POLLIN;
}
fds[0].fd = serverListenSkt;
int maxfd = serverListenSkt;
while (true)
{
poll(fds, maxfd + 1, -1);
for (int i = 0; i <= maxfd; i++)
{
if (fds[i].revents & POLLIN)
{
int curFd = fds[i].fd;
if (curFd == serverListenSkt)
{
struct sockaddr_in sktaddr;
int size=sizeof(sktaddr);
int clientConn=accept(serverListenSkt,(struct sockaddr *)(&sktaddr),&size);
int clientConn = accept(serverListenSkt, 0, 0);
int i = 0;
while (i < 1024)
{
if (fds[i].fd == -1)
{
fds[i].fd = clientConn;
break;
}
i++;
}
maxfd = maxfd > i ? maxfd : i;
}
else
{
int ret = recvAndSend(curFd);
if (ret == -1)
{
fds[curFd].fd = -1;
}
else if (ret == -3)
goto shutdown;
}
}
}
}
shutdown:
close(serverListenSkt);
}
在实际的实践过程中,虽然没有使用这两种模型,但我还是编写了响应的代码并进行了测试,这有助于我进一步学习epoll以及加深对c++I/O多路复用的理解。
epoll
是一种I/O多路复用的技术,它是Linux特有的。epoll
的底层是红黑树,被设计用来替代select
和poll
。epoll
可以处理大量的文件描述符,而且不受FD_SETSIZE
的限制,因此在处理大量并发连接时,epoll
的效率更高。epoll
的主要函数有epoll_create
、epoll_ctl
和epoll_wait
。
epoll
的工作方式是,当监视的文件描述符发生变化时,只通知用户发生变化的文件描述符,而不是所有的文件描述符。这样,即使文件描述符的数量很大,也可以高效地处理,不用遍历所有的文件描述符。这是epoll
相比select
和poll
的一个主要优势。
下面的代码简单演示了epoll
的使用方法
void ServerListen::acceptWithEpoll()
{
ep = epoll_create(1);
if (-1 == ep)
{
std::cout << "error happen while crete a epoll instance" << std::endl;
close(serverListenSkt);
return;
}
struct epoll_event ep_evt;
ep_evt.events = EPOLLIN;
ep_evt.data.fd = serverListenSkt;
if (-1 == epoll_ctl(ep, EPOLL_CTL_ADD, serverListenSkt, &ep_evt)) // ADD时传入的ep_evt看似是指针,其实会被拷贝
{
std::cout << "error happen while add serverListenSkt to the epoll instance" << std::endl;
close(serverListenSkt);
return;
};
struct epoll_event evs[1024]; // 用于返回
int size = sizeof(evs) / sizeof(evs[0]);
while (true)
{
int retNum = epoll_wait(ep, evs, size, -1);
for (int i = 0; i < retNum; ++i)
{
int curFd = evs[i].data.fd;
if (curFd == serverListenSkt)
{
int clientConn = accept(serverListenSkt, 0, 0);
//水平触发---->边缘触发
//ep_evt.events = EPOLLIN | EPOLLLT;
ep_evt.events = EPOLLIN | EPOLLET;
ep_evt.data.fd = clientConn; // 借用
if (-1 == epoll_ctl(ep, EPOLL_CTL_ADD, clientConn, &ep_evt))
{
std::cout << "error happen while add a new clientConn to the epoll instance" << std::endl;
close(clientConn);
continue;
}
Info *info = new Info(clientConn);
infos[clientConn] = info;
}
else
{
thpool.addTask(recvAndSend, (void *)infos[curFd]);
}
}
if (shutdown)
break;
}
thpool.wait();
for (auto it = infos.begin(); it != infos.end(); ++it)
{
int curFd = it->first;
delete infos[curFd];
close(curFd);
}
close(serverListenSkt);
}
在实际的实践过程中,我使用epoll
模型搭配线程池来处理用户的请求。此时问题就产生了:有一次当我模拟客户端发起一个请求时,服务端对这个请求竟然进行了多次处理。这是怎么回事呢?
在调试一段时间后发现,这根epoll
树上节点的两种触发模式有关,分别是水平触发(Level Triggered,LT)和边缘触发(Edge Triggered,ET)。
在水平触发模式(默认)下,如果一个节点(文件描述符)收到的请求未被处理,则下次调用epoll_wait方法时仍然会返回这个节点的请求。我们都知道,放入线程池内的工作不一定会被即时的完成,这就导致同一个请求可能导致对该请求的响应任务被多次放入到线程池中,从而导致了上述问题。而在边缘触发模式下,单个请求只会被epoll_wait方法响应一次,避免了此类问题产生。
在上面的注释中可以看到我将客户端连接的文件描述符的触发模式从水平触发改成了边缘触发。
c++的I/O多路复用是socket编程中至关重要的一部分,学习I/O多路复用技术,我深感其高效性和灵活性。通过该技术,系统能够同时监听多个I/O事件,如文件读写、网络连接等,大大提高了并发处理能力。在实际应用中,无论是服务器开发还是高性能网络编程,I/O多路复用都扮演着重要角色。学习过程中,我深入理解了select、poll、epoll等机制的工作原理和差异,感受到了它们在不同场景下的适用性。这种技术让我对系统编程有了更深的认识,也为后续的学习和实践打下了坚实的基础。