目录
一、套接字编程基本流程
二、TCP流式协议及Socket编程的recv()和send()
三、读写无阻塞-完美掌握I/O复用
select()函数详解
poll()函数详解
epoll () 函数详解
一、套接字编程基本流程
原文链接:Socket编程权威指南(一)打通网络通信的任督二脉_seqpacket-CSDN博客
Socket进行编程通常包括以下几个步骤:
-
- 创建Socket
- 绑定 Socket(绑定地址信息)
- 监听连接请求(TCP服务器)
- 接受客户端链接
- 发送和接收数据
- 关闭Socket
1、创建socket:socket()返回新创建的套接字描述符(sockfd,一个非负整数)
int socket(int domain, int type, int protocol);
创建初始套接字描述符,用于指定通信协议(ipv4..)、套接字类型(tcp/udp..)、以及特定协议(通常默认)
2、绑定套接字地址:bind() 返回0
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
将创建的套接字描述符与服务器的ip+port进行绑定,用于指定服务器的地址将用哪种传输协议进行传输。
sockaddr通常是sockaddr_in
结构。
struct sockaddr_in{
sa_family_t sin_family;//类型,ipv4
uint16_t sin_port;
struct in_addr sin_addr;
char sin_zero[8];
};
3、服务器监听客户端连接请求:listen()返回0
int listen(int sockfd, int backlog);
sockfd是绑定地址后的套接字描述符(必须在bind()后面);backlog指定内核应该排队的最大未完成连接的数量。这个值应该足够大,以避免在高负载情况下丢失连接请求。
- 服务器首先创建一个套接字并绑定到一个地址。
- 调用
listen()
使套接字变为被动监听模式。 - 服务器随后可以使用
accept()
函数接受客户端的连接请求。
4、服务器接收客户端连接:accept()返回一个新的套接字描述符(clifd),用于与已连接的客户端通信
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
sockfd
:已经调用过listen()
的套接字描述符。addr
:(可选)指向sockaddr
结构的指针,用于存储连接客户端的地址信息。如果不需要客户端地址,可以设置为NULL
。addrlen
:(可选)指向socklen_t
类型的指针,用于存储addr
结构的大小。如果addr
是NULL
,这个参数也会被忽略。
用于接受一个已经建立的连接请求,通常在服务器端使用。当服务器调用 listen()
函数后,它会进入监听状态,等待客户端的连接请求。
5、进行数据传输:read()、write()
6、关闭连接:close(clifd)、close(sockfd)
#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
int main() {
// 创建Socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// 绑定地址信息
struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(8000);
bind(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr));
// 监听连接
listen(sockfd, 5);
// 接收客户端连接
struct sockaddr_in cliaddr;
socklen_t cliaddrlen = sizeof(cliaddr);
int clifd = accept(sockfd, (struct sockaddr*)&cliaddr, &cliaddrlen);
// 读取客户端发送数据并回射
char buffer[1024];
ssize_t nbytes = read(clifd, buffer, sizeof(buffer));
write(clifd, buffer, nbytes);
// 关闭连接
close(clifd);
close(sockfd);
return 0;
}
二、TCP流式协议及Socket编程的recv()和send()
原文链接:Socket编程权威指南(二)完美掌握TCP流式协议及Socket编程的recv()和send()_enotconn-CSDN博客
TCP 作为流式协议,其设计目标是提供可靠的数据传输服务。它通过多种机制确保数据的正确、有序传输,并通过拥塞控制和流量控制适应不同的网络条件。
拥塞控制是确保可靠数据传输协议有效运作的关键组成部分,因此,在TCP中,发送缓冲区和接收缓冲区成为了必不可少的元素。
在标准的Linux操作系统中,TCP的发送缓冲区和接收缓冲区默认的大小通常被设置为208KB。这意味着,如果进程A没有及时从其接收缓冲区中提取数据,那么传入的数据将继续在缓冲区内积累,直至达到其容量上限。
由于TCP面向字节流传输,因此不同TCP包到达接收缓冲区需要从一连串的字节流中区分出哪个包(粘包问题)。
采用包头+包体的策略可以解决以上问题:
- Step 1: 首先从接收缓冲区读取固定大小的包头(例如20字节)。
- Step 2: 解析包头,从中获取数据包的总长度,这里假设包头中包含的数据长度字段名为
Header.Length
。 - Step 3 : 根据
Header.Length
的值,确定接下来需要从接收缓冲区读取的数据量。
例如,如果包头之后的数据总长度为1048字节,减去已读取的20字节包头,还需读取1028字节的数据。
对于建立连接后的数据传输,通常使用recv()和send()。
1、recv()
//ssize_t :表示可以存储任意对象大小的有符号整数
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
sockfd
:套接字描述符,表示要从中读取数据的 TCP 套接字。buf
:指向一个缓冲区的指针,用于存储接收到的数据。len
:缓冲区的大小,即buf
可以存储的最大字节数。- flags:用来修改recv()行为的选项。常用的值包括:
-
0
:正常接收数据。MSG_PEEK
:窥视接收的数据,不从接收缓冲区中移除数据。MSG_WAITALL
:等待直到接收到len
个字节的数据,或者出现错误。
返回值:成功时,返回接收到的字节数,该值通常小于或等于 len
。
(1)使用场景:
- 主要用于 TCP 套接字上的数据接收。对于 UDP 套接字,通常使用
recvfrom()
函数。
(2)阻塞和非阻塞行为:
- 默认情况下,
recv()
是阻塞的,它会等待直到至少接收到一个字节的数据。 - 对于非阻塞套接字,如果接收缓冲区中没有数据,
recv()
会立即返回,返回值为 0。
(3)与 read()
的区别:
read()
是一个通用的系统调用,用于读取文件描述符,而recv()
专门用于套接字。recv()
可以处理套接字选项和状态,而read()
不能。
2、send()
本质上是向发送缓冲区中写入数据,内核在发送 TCP 数据时,通常会使用 Nagle 算法把多个小的数据包合并成一个发送给另一端,以提高效率。
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
sockfd
:套接字描述符,表示要从中发送数据的套接字。buf
:指向要发送数据缓冲区的指针。len
:要发送数据的长度,单位为字节。- flags:用来修改发送行为的选项。常用的值包括:
-
0
:正常发送数据。MSG_DONTWAIT
:使send()
调用非阻塞。MSG_MORE
:暗示更多的数据要发送,可以用于优化传输效率。
返回值:成功时,返回已发送的字节数,该值通常小于或等于 len
。
(1)使用场景
主要用于已连接的 TCP 套接字上的数据发送。对于 UDP 套接字,通常使用 sendto()
函数。
(2)阻塞和非阻塞行为
- 默认情况下,
send()
是阻塞的,它会等待直到数据被发送。
对于非阻塞套接字,如果数据不能立即发送,send()
会返回 -1
并设置 errno
为 EAGAIN
或 EWOULDBLOCK
。
(3)与 write()
的区别
write()
是一个通用的系统调用,用于写文件描述符,而send()
专门用于套接字。send()
可以处理套接字选项和状态,而write()
不能。
为了实现更为复杂的非阻塞操作,recv()和send()可以结合select()和poll()。
三、读写无阻塞-完美掌握I/O复用
问题描述:
tcp流式协议以及recv()和send()这两个关键函数,用于从套接字中读取和发送数据。不过,仅依赖这两个函数存在一个明显的缺陷:如果一个套接字阻塞了,整个进程将无法处理其他套接字,效率低下。
问题解决思路:
为了解决这个问题,I/O复用模型应运而生,它使用单个线程高效地监视多个文件描述符。
I/O 复用模型的工作原理:
-
- 基本概念: I/O 复用模型通过将 I/O 操作与特定的事件关联起来,使得进程或线程可以在数据准备好时才进行操作,而不是不断地轮询。
- 使用系统调用: I/O 复用通常依赖于特定的系统调用,如
select()
,poll()
, 和epoll()
(在 Linux 上)。这些调用允许进程监控多个 I/O 描述符的状态。 - 监控 I/O 描述符: 进程提供一个 I/O 描述符的列表给 I/O 复用系统调用,请求监控这些描述符上特定的事件,例如可读、可写或异常状态。
- 阻塞等待: I/O 复用调用本身可能是阻塞的,直到以下情况发生:
-
-
- 至少有一个 I/O 描述符准备好了 I/O 操作。
- 超时时间到达,即使没有 I/O 描述符准备好。
-
-
- 事件通知: 当 I/O 复用系统调用返回时,它会通知进程哪些 I/O 描述符已经准备好了 I/O 操作,进程可以据此执行相应的操作。
- 提高效率: 与为每个 I/O 流创建线程或进程相比,I/O 复用可以显著减少并发处理的开销,因为它通过单个系统调用管理多个 I/O 流。
select()
函数:select()
是最基本的 I/O 复用机制,它允许进程监控多个描述符的 I/O 状态,但它有一些限制,如描述符数量的限制和性能问题。poll()
函数:poll()
提供与select()
类似的功能,但没有描述符数量的限制,但仍然存在性能问题,尤其是在大量描述符时。epoll()
函数:epoll()
是 Linux 特有的 I/O 复用机制,它比select()
和poll()
更高效,因为它使用事件通知机制,并且可以处理大量描述符。- 水平触发与边缘触发: I/O 复用可以工作在两种模式下:
-
- 水平触发(Level-triggered):只要条件满足,每次调用都会返回。
- 边缘触发(Edge-triggered):只有在状态变化时才返回,可以提高性能,但编程模型更复杂。
select()函数详解
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
-
nfds
:监视的文件描述符集合中最大的描述符加一(描述符集合的数量)readfds
:指向需要监视读状态的文件描述符集合的指针。writefds
:指向需要监视写状态的文件描述符集合的指针。exceptfds
:指向需要监视异常状态的文件描述符集合的指针。timeout
:指向超时时间的指针,可以是NULL
表示无限期等待。
当 select()
被调用时,它会阻塞直到以下情况之一发生:
-
- 至少有一个文件描述符准备好了 I/O 操作。
- 发生了异常。
- 超时时间到达。
select()
会更新传入的集合参数,以反映哪些文件描述符已经准备好 I/O 操作。
返回值:成功时,返回准备好的文件描述符的数量。
宏功能的说明:FD_ZERO()
, FD_SET()
, FD_CLR()
, 和 FD_ISSET()
是与 select()
函数一起使用的宏,它们用于操作文件描述符集合(fd_set
)。
(1)、FD_ZERO()
作用:将 fd_set 结构初始化为零,即清空集合中的所有文件描述符。
用法:FD_ZERO(&fdset); 其中 fdset 是 fd_set 类型的变量。
(2)、FD_SET()
作用:将指定的文件描述符添加到 fd_set 结构中。
用法:FD_SET(fd, &fdset); 其中 fd 是要添加的文件描述符,fdset 是 fd_set 类型的变量。
注意:如果文件描述符已经在集合中,再次调用 FD_SET() 不会有任何效果。
(3)、FD_CLR()
作用:从 fd_set 结构中删除指定的文件描述符。
用法:FD_CLR(fd, &fdset); 其中 fd 是要删除的文件描述符,fdset 是 fd_set 类型的变量。
注意:如果文件描述符不在集合中,调用 FD_CLR() 没有效果。
(4)、FD_ISSET()
作用:检查指定的文件描述符是否在 fd_set 结构中。
用法:if (FD_ISSET(fd, &fdset)) { ... } 其中 fd 是要检查的文件描述符,fdset 是 fd_set 类型的变量。
返回值:如果文件描述符在集合中,返回非零值(通常是 1);如果不在集合中,返回 0。
select()配合宏功能的使用步骤:
- 使用
FD_ZERO()
初始化fd_set
结构。 - 使用
FD_SET()
将需要监视的文件描述符添加到集合中。 - 调用
select()
函数,传入fd_set
结构。 - 调用
select()
后,使用FD_ISSET()
检查哪些文件描述符已经准备好 I/O 操作。 - 使用
FD_CLR()
从集合中删除已经处理过的文件描述符,以便在下一次select()
调用中不再监视它们。
注意事项:
select()
有文件描述符数量的限制(通常是 1024),对于大量并发连接,可能需要使用poll()
或epoll()
等更高级的 I/O 复用技术。- 在调用
select()
之前,需要使用FD_ZERO()
,FD_SET()
,FD_CLR()
,FD_ISSET()
等宏来初始化和操作文件描述符集合。
// 在这个例子中,我们首先将监听套接字加入masterfds集合。
//然后在每次循环中,将masterfds复制到readfds,并调用select()进行监视。
//如果监听套接字就绪,则接受新的连接并将数据套接字加入masterfds。
//如果数据套接字就绪,则可以对其进行读写操作。
#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
int main() {
//创建服务器端socket套接字
int listensock = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(8000);
//绑定套接字与服务器地址端口号
bind(listensock, (struct sockaddr*)&servaddr, sizeof(servaddr));
//监听该套接字
listen(listensock, 5);
//定义文件描述符集合
fd_set readfds, masterfds;
//初始化
FD_ZERO(&masterfds);
//将服务器读状态套接字放入master文件描述符集合(其实就是表示是否本套接字有连接情况)
FD_SET(listensock, &masterfds);
while (true) {
//循环将监听套接字集合更新到read集合中
readfds = masterfds;
//select阻塞监视监听套接字集合,会更新传入的集合,只留下已经准备好IO操作的文件描述符
int nfds = select(listensock + 1, &readfds, NULL, NULL, NULL);
//如果监听套接字就绪,与客户端套接字建立连接
if (FD_ISSET(listensock, &readfds)) {
struct sockaddr_in cliaddr;
socklen_t cliaddrlen = sizeof(cliaddr);
//建立连接
int datafds = accept(listensock, (struct sockaddr*)&cliaddr, &cliaddrlen);
//把客户端套接字(数据套接字)加入到master,用于后续更新
FD_SET(datafds, &masterfds);
}
//实现一个监听套接字监听多个数据套接字的IO操作
for (int datafds = 0; datafds < nfds; datafds++) {
//如果数据套接字就绪进行数据传输操作
if (FD_ISSET(datafds, &readfds)) {
// Handle data from datafds
}
}
}
close(listensock);
return 0;
}
select()函数的限制:文件描述符数量限制。
具体原因:
select使用的文件描述符集合的数据结构为fd_set:
typedef struct {
unsigned long fds_bits[FD_SETSIZE / (8 * sizeof(unsigned long))];
} fd_set;
这是一个固定大小的数组,FD_SETSIZE
是一个常量,定义了 fd_set
可以表示的最大文件描述符数量。通常这个值是 1024。
poll()函数详解
select()使用描述符集来监视描述符,主要存在两方面缺陷:
(1)采用fd_set结构,导致存在最大文件描述符数量的限制,无法适应大量并发IO操作
(2)存在复制描述符集的开销,主要原因是每次调用select()时,它会修改传入的集合fd_set,只保留已经准备好进行 I/O 操作的描述符,因此需要在进入循环开始先重置描述符集合,存在复制开销。
-
fd_set
的修改机制
-
-
- 设置准备好的文件描述符:
-
在调用 select()
之前,你会用 FD_SET
宏将感兴趣的文件描述符添加到 fd_set
中。
当 select()
返回时,它会修改传入的 fd_set
,只保留那些已经准备好进行 I/O 操作的文件描述符。
-
-
- 清除未准备好的文件描述符:
-
select()
会清除(unset)那些没有准备好的文件描述符。这意味着,如果一个文件描述符在调用 select()
时没有准备好,它将不再存在于返回的 fd_set
中。
poll()
是一种 I/O 多路复用系统调用,它提供了一种机制来监视多个文件描述符(file descriptors)的状态,类似于 select()
函数。poll()函数克服了select()的部分缺陷,它采用pollfd结构数组(std::vector<struct pollfd> fds;)来监视,而不是描述符集,避免了每次调用时复制描述符集的开销。
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
-
fds
:指向struct pollfd
数组的指针,数组中的每个元素都包含了要监视的文件描述符和相关的事件类型。nfds
:数组fds
中元素的数量。timeout
:等待时间,单位为毫秒。如果设置为-1
,表示无限期等待;设置为0
表示非阻塞调用,立即返回。
struct pollfd
结构:
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 需要监视的事件类型 */
short revents; /* 事件发生后的状态 */
};
-
fd
:需要监视的文件描述符。- events:需要监视的事件类型,可以是以下宏的组合:
-
-
POLLIN
:有数据可读。POLLOUT
:写入不会阻塞。POLLPRI
:有紧急数据可读。POLLERR
:发生错误。POLLHUP
:对端关闭连接。POLLNVAL
:文件描述符不是有效的监视对象。
-
-
revents
:实际发生的事件,函数返回后由系统填充。
返回值:成功时,返回准备好的文件描述符的数量。
工作原理:
-
- 初始化
pollfd
数组:为每个需要监视的文件描述符设置一个pollfd
结构,并指定需要监视的事件类型。 - 调用
poll()
:传入pollfd
数组、数组的大小和超时时间。 - 等待事件:
poll()
函数会阻塞,直到以下情况之一发生:
- 初始化
-
-
- 至少有一个文件描述符准备好了 I/O 操作。
- 超时时间到达。
-
-
- 处理结果:
poll()
函数返回后,检查pollfd
数组中的revents
字段,以确定哪些事件发生了。
- 处理结果:
epoll () 函数详解
select()和poll()虽然调用的是IO复用的机制,但是前者存在最大描述符数量限制以及描述符集合复制开销;后者存在每次调用poll时加入全部描述符,因此这两种方法都无法应对大量的并发IO操作。
epoll
在处理大量并发连接时具有明显的优势,因为它使用基于事件的模型,可以减少 CPU 和内存的使用。
1、核心概念
- epoll 实例:使用
epoll_create()
创建,代表一个监视的集合,理解为一个监视多个文件描述符事件的对象 - 事件:可以是读、写、错误等。
- 文件描述符:需要被监视的 I/O 对象。
- 回调机制:当文件描述符上的事件发生时,
epoll
会通知应用程序。
2、函数原型
- 创建 epoll 实例
#include <sys/epoll.h>
//自从Linux2.6.8版本以后,size值其实是没什么用的,不过要大于0,因为内核可以动态的分配大小,
//所以不需要size这个提示了。
int epoll_create(int size);
size
:建议的初始大小,实际上创建的实例大小由内核决定。
- 添加/修改文件描述符
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
-
epfd
:epoll 实例的文件描述符,监视多个文件描述符的对象,由epoll_create创建。op
:操作类型,可以是EPOLL_CTL_ADD
(添加)、EPOLL_CTL_MOD
(修改)或EPOLL_CTL_DEL
(删除)。fd
:需要监视的文件描述符。event
:指向epoll_event
结构的指针,指定了要监视的事件和相关的回调数据。
- 等待事件
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
-
epfd
:epoll 实例的文件描述符。events
:用于存储发生的事件的数组。maxevents
:数组events
的最大容量。timeout
:等待时间,单位为毫秒。如果设置为-1
,表示无限期等待。
epoll_event
结构:
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
- events:事件掩码,可以是以下宏的组合:
-
EPOLLIN
:有数据可读。EPOLLOUT
:写入不会阻塞。EPOLLPRI
:有紧急数据可读。EPOLLERR
:发生错误。EPOLLHUP
:对端关闭连接。
data
:用户自定义的数据,可以是任何类型的指针,用于在事件发生时传递额外信息。
3、工作原理
- 第一步,创建 epoll 实例:使用
epoll_create()
创建一个 epoll 实例。 - 第二步,添加文件描述符:使用
epoll_ctl()
将需要监视的文件描述符添加到 epoll 实例中,并设置要监视的事件。 - 第三步,等待事件:调用
epoll_wait()
等待事件发生。与select()
和poll()
不同,epoll_wait()
只返回已经发生的事件,减少了不必要的轮询。 - 第四步,处理事件:遍历
epoll_wait()
返回的事件数组,处理每个发生的事件。
#include <sys/epoll.h>
#include <unistd.h>
#include <stdio.h>
int main() {
int epfd = epoll_create(1); // 创建 epoll 实例
//event定义监视的事件类型;events数组保存实际发生的事件
struct epoll_event event, events[10];
if (epfd == -1) {
perror("epoll_create");
return 1;
}
// 初始化事件
event.data.fd = STDIN_FILENO; // 监视标准输入
event.events = EPOLLIN;
// 添加文件描述符到 epoll 实例
if (epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &event) == -1) {
perror("epoll_ctl");
return 1;
}
// 等待事件,把发生的事件放入events数组中,并返回已经发生的事件的数量
int nfds = epoll_wait(epfd, events, 10, -1);
if (nfds == -1) {
perror("epoll_wait");
return 1;
}
// 处理事件
for (int i = 0; i < nfds; i++) {
if (events[i].events & EPOLLIN) {
printf("Data is available to read on fd %d\n", events[i].data.fd);
}
}
close(epfd);
return 0;
}