一、动态建立连接块链表操作
其定义是从上往下套了两层壳:
然后使用尾插法动态建立一个新的连接块(动态建立连接块并将其初始化,然后找到当前链表中的最后一个连接块,将新创建的连接块添加到链表末尾endblk->next = newblk;
)
calloc
是 C 语言中的一个标准库函数,用于动态分配内存。与 malloc
不同,calloc
会将分配的内存初始化为 0。
// 单个连接
typedef struct zv_connect_s{
// 本连接的客户端fd
int fd;
// 本连接的读写buffer
char rbuffer[max_buffer_len];
size_t rcount; // 读buffer的实际大小
char wbuffer[max_buffer_len];
size_t wcount; // 写buffer的实际大小
size_t next_len; // 下一次读数据长度(读取多个包会用到)
// 本连接的回调函数--accept()/recv()/send()
ZV_CALLBACK cb;
}zv_connect;
// 连接块的头
typedef struct zv_connblock_s{
struct zv_connect_s *block; // 指向的当前块,注意大小为 connblock_size
struct zv_connblock_s *next; // 指向的下一个连接块的头
}zv_connblock;
// 反应堆结构体
typedef struct zv_reactor_s{
int epfd; // epoll文件描述符
// struct epoll_event events[epoll_events_size]; // 就绪事件集合
struct zv_connblock_s *blockheader; // 连接块的第一个头
int blkcnt; // 现有的连接块的总数
}zv_reactor;
---------------------------------------------------------------------------------------------------
\\创建一个新的连接块(尾插法)
int zv_create_connblock(zv_reactor* reactor) {
// 1. 检查传入的 reactor 指针是否有效
if (!reactor) return -1;
// 2. 初始化一个新的连接块(zv_connblock 结构体)
zv_connblock* newblk = (zv_connblock*)calloc(1, sizeof(zv_connblock));
if (newblk == NULL) return -1; // 如果内存分配失败,返回 -1
// 3. 初始化连接块中的连接数组(zv_connect 数组)
newblk->block = (zv_connect*)calloc(connblock_size, sizeof(zv_connect));
if (newblk->block == NULL) return -1; // 如果内存分配失败,返回 -1
newblk->next = NULL; // 新块的 next 指针为空
// 4. 找到链表中的最后一个连接块
zv_connblock* endblk = reactor->blockheader;
while (endblk->next != NULL) {
endblk = endblk->next;
}
// 5. 将新创建的连接块添加到链表末尾
endblk->next = newblk;
// 6. 增加连接块计数器
reactor->blkcnt++;
// 7. 返回 0 表示成功
return 0;
}
二、释放销毁连接块链表操作
使用双指针一直遍历连接块链表,因为连接块链表有套壳操作,所有free的时候一定有一个先后顺序。这里是一定先free(curblk->block)
然后再free(curblk)
。
void destory_reactor(zv_reactor* reactor) {
if (reactor) {
// 1. 关闭epoll文件描述符
close(reactor->epfd);
// 2. 获取连接块链表的头部
zv_connblock* curblk = reactor->blockheader;
zv_connblock* nextblk = reactor->blockheader;
// 3. 遍历链表,释放所有连接块
do {
curblk = nextblk; // 当前块指向当前处理的连接块
nextblk = curblk->next; // 获取下一个连接块的指针
// 4. 释放当前连接块中的block指针指向的内存
if (curblk->block) free(curblk->block);
// 5. 释放当前连接块结构体的内存
if (curblk) free(curblk);
} while (nextblk != NULL); // 如果下一个连接块不为空,继续处理
}
}
三、服务器套接字的初始化操作
重点在于先建立一个套接字,然后设置网络地址(可以自定义或者采用本地地址)和端口,并利用套接字去监听这个网络地址(可以自定义或者采用本地地址)和端口。
int init_sever(int port) {
// 1. 创建TCP服务端套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0); // io
// 2. 设置非阻塞模式(此处被注释掉,默认使用阻塞模式)
// fcntl(sockfd, F_SETFL, O_NONBLOCK); // 非阻塞
// 3. 设置网络地址和端口
struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(struct sockaddr_in)); // 清空结构体
servaddr.sin_family = AF_INET; // 设置为IPv4地址族
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定到本地所有可用的网络接口
servaddr.sin_port = htons(port); // 设置端口号(将主机字节序转为网络字节序)
// 4. 将套接字绑定到指定的地址和端口上
if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr))) {
printf("bind failed: %s", strerror(errno)); // 绑定失败,打印错误信息
return -1; // 返回错误码
}
// 5. 将套接字设置为监听状态,允许最大连接队列长度为10
listen(sockfd, 10); // 使服务器能够接受连接请求
// 6. 打印成功信息,返回套接字描述符
printf("listen port: %d, sockfd: %d\n", port, sockfd);
return sockfd; // 返回套接字文件描述符,用于后续的 I/O 操作
}
四、利用epoll“基于事件驱动”方式去接收客户端的连接请求
将listenfd添加进epoll,,核心点在于:注册事件、监听事件、处理事件。
int set_listener(zv_reactor *reactor, int listenfd, ZV_CALLBACK cb) {
// 1. 检查参数的有效性
if (!reactor || !reactor->blockheader) return -1;
// 2. 在Reactor的连接块中设置listenfd和回调函数
reactor->blockheader->block[listenfd].fd = listenfd; // 保存监听套接字
reactor->blockheader->block[listenfd].cb = cb; // 保存回调函数(如accept) \\处理事件
// 3. 创建epoll事件
struct epoll_event ev; \\注册事件
ev.data.fd = listenfd; // 将监听套接字描述符传入事件数据
ev.events = EPOLLIN; // 监听读事件(即有客户端连接请求时触发)
// 4. 将监听套接字添加到epoll实例中
epoll_ctl(reactor->epfd, EPOLL_CTL_ADD, listenfd, &ev); \\监听事件
// 5. 返回0表示成功
return 0;
}
五、整除得到这个新来的fd将被分配的连接块、取余找到在连接块中具体单个连接的位置(注意是分配资源)
连接块 (Connection Block):每个连接块包含了一组连接信息(在你的代码中是 zv_connect
结构体),其大小为 connblock_size
。通过分块来管理连接,代码可以有效组织大量连接,避免单个数组太大导致的内存管理问题。
文件描述符 (File Descriptor, fd):文件描述符是操作系统为每个打开的文件、套接字等资源分配的整数值。它通常从小到大顺序分配。
-
假设
connblock_size = 1024
,那么fd
范围为0-1023
的连接信息会存储在第一个连接块中,fd
范围为1023-2047
的连接信息会存储在第二个连接块中,以此类推。
计算公式:
-
连接块编号 =
fd / connblock_size
-
连接块内位置 =
fd % connblock_size
zv_connect* zv_connect_idx(zv_reactor* reactor, int fd) {
// 1. 检查传入的 reactor 指针是否有效
if (!reactor) return NULL;
// 2. 计算 fd 所在的连接块的索引
int blkidx = fd / connblock_size;
// 3. 如果计算得到的 blkidx 超过当前的连接块数目,创建新的连接块
while (blkidx >= reactor->blkcnt) {
zv_create_connblock(reactor);
// printf("create a new connblk!\n");
}
// 4. 找到 blkidx 对应的连接块
zv_connblock* blk = reactor->blockheader;
int i = 0;
while (++i < blkidx) {
blk = blk->next;
}
// 5. 返回指向 blk 中 fd 对应连接位置的指针
return &blk->block[fd % connblock_size];
}
六、接受客户端的连接
struct sockaddr_in clientaddr; // 用于存储客户端的地址信息
socklen_t len_sockaddr = sizeof(clientaddr);
int clientfd = accept(fd, (struct sockaddr*)&clientaddr, &len_sockaddr);\\这个fd是服务器的套接字文件描述符(通过这个进行联系的)
\\错误/失败处理
if(clientfd < 0){
printf("accept() error: %s\n", strerror(errno));
return -1;
}
这里使用 accept()
函数来接受来自客户端的连接请求。
-
fd
是服务器的监听套接字,它已经在之前被设置为监听模式。 -
clientaddr
用于存储连接的客户端地址信息。 -
clientfd
是新建立的客户端套接字,用于之后的通信。如果accept()
返回的值小于 0,则表示连接失败。
假设有以下情况:
-
服务器:
-
地址:
127.0.0.1
(本地回环地址) -
端口:
8080
-
-
客户端:
-
地址:
127.0.0.1
(也在本地,因为客户端和服务器都在同一台机器上) -
端口:系统自动分配的一个高位临时端口,如
54321
-
具体通信过程
-
服务器启动并监听:服务器在
127.0.0.1:8080
启动,并开始监听连接请求。 -
客户端发起连接:客户端发起连接请求时,操作系统会为它分配一个本地端口,如
54321
。然后客户端连接到127.0.0.1:8080
。 -
连接建立:服务器接受连接并返回一个新的套接字,代表与该客户端的连接。客户端和服务器现在通过
127.0.0.1:8080
(服务器)和127.0.0.1:54321
(客户端)进行通信。
七、内核接收/发送缓冲区需要自己定义吗?
不需要,内核接收缓冲区是由操作系统自动管理的。当你创建一个套接字并绑定到一个端口时,操作系统会为这个套接字分配一个默认大小的接收缓冲区,用于存放从网络接收到的数据。
不过,你可以通过一些系统调用或套接字选项来自定义这个缓冲区的大小。例如,可以使用 setsockopt
函数来调整接收缓冲区的大小,以满足应用程序的需要:
int recv_buf_size = 1024 * 64; // 设置缓冲区大小为64KB
setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &recv_buf_size, sizeof(recv_buf_size));
但即使你不调整,操作系统会为你处理这个缓冲区的分配和管理。通常,默认的缓冲区大小已经足够应对一般的网络流量。
当有新的数据到达时,指的是数据已经从客户端传输到了服务器,并且服务器的操作系统已经将这些数据暂时存储在内核空间的某个缓冲区中(通常是套接字的接收缓冲区)。这个缓冲区是操作系统用来存储接收到的数据,直到服务器程序准备好读取它们。
因此,"新的数据到达"意味着数据已经安全地存放在服务器的接收缓冲区中,并且不会丢失。服务器程序会被通知这些数据的到达,并且可以调用相关的系统调用(如 recv
或 read
)从这个缓冲区中读取数据。
八、epoll接收回调函数
"由于当前fd可读所以没有阻塞" 这句话的意思是:
本来read()是阻塞访问的
但因为使用了epoll机制(非阻塞、异步方式),在调用 recv()
函数时,文件描述符(fd
)已经准备好进行读取操作,因此 recv()
函数会立即返回,而不会阻塞等待数据的到来(因为进入这个函数便肯定是有数据可读的)。
// 回调函数:接收数据
int recv_cb(int clientfd, int event, void* arg){
zv_reactor* reactor = (zv_reactor*)arg;
zv_connect* conn = zv_connect_idx(reactor, clientfd);
int recv_len = recv(clientfd, conn->rbuffer+conn->rcount, conn->next_len, 0); // 由于当前fd可读所以没有阻塞
if(recv_len < 0){
printf("recv() error: %s\n", strerror(errno));
close(clientfd);
// return -1;
exit(0);
}else if(recv_len == 0){
// 重置对应的连接块
conn->fd = -1;
conn->rcount = 0;
conn->wcount = 0;
// 从epoll监听事件中移除
epoll_ctl(reactor->epfd, EPOLL_CTL_DEL, clientfd, NULL);
// 关闭连接
close(clientfd);
printf("close clientfd:%d\n", clientfd);
}else if(recv_len > 0){
conn->rcount += recv_len;
// conn->next_len = *(short*)conn->rbuffer; // 从tcp协议头中获取数据长度,假设前两位是长度
// 处理接收到的字符串,并将需要发回的信息存储在缓冲区中
printf("recv clientfd:%d, len:%d, mess: %s\n", clientfd, recv_len, conn->rbuffer);
// conn->rcount = kv_protocal(conn->rbuffer, max_buffer_len);
// 将kv存储的回复消息(rbuffer)拷贝给wbuffer
// printf("msg:%s len:%ld\n", msg, strlen(msg));
memset(conn->wbuffer, '\0', max_buffer_len);
memcpy(conn->wbuffer, conn->rbuffer, conn->rcount);
conn->wcount = conn->rcount;
memset(conn->rbuffer, 0, max_buffer_len);
conn->rcount = 0;
// 将本连接更改为epoll写事件
conn->cb = send_cb;
struct epoll_event ev;
ev.data.fd = clientfd;
ev.events = EPOLLOUT;
epoll_ctl(reactor->epfd, EPOLL_CTL_MOD, clientfd, &ev);
}
return 0;
}
九、客户端发送什么数据,服务器立即回环什么数据的实现
在recv_cb函数中,处理接收到的字符串,并将需要发回的信息存储在**自定义发送缓冲区(注意这个不是内核缓冲区)**中,并将回调函数修改为send_cb
所以只能配合:
在main函数中用户主动调用将本连接更改后的epoll写事件
两者结合便实现数据回环
//在recv_cb函数中
// 处理接收到的字符串,并将需要发回的信息存储在缓冲区中
printf("recv clientfd:%d, len:%d, mess: %s\n", clientfd, recv_len, conn->rbuffer);
// conn->rcount = kv_protocal(conn->rbuffer, max_buffer_len);
// 将kv存储的回复消息(rbuffer)拷贝给wbuffer
// printf("msg:%s len:%ld\n", msg, strlen(msg));
memset(conn->wbuffer, '\0', max_buffer_len);
memcpy(conn->wbuffer, conn->rbuffer, conn->rcount);
conn->wcount = conn->rcount;
memset(conn->rbuffer, 0, max_buffer_len);
conn->rcount = 0;
// 将本连接更改为epoll写事件
conn->cb = send_cb;
struct epoll_event ev;
ev.data.fd = clientfd;
ev.events = EPOLLOUT;
epoll_ctl(reactor->epfd, EPOLL_CTL_MOD, clientfd, &ev);
---------------------------------------------------------------------------------------------------------------------
//在main函数中主动调用将本连接更改后的epoll写事件
while(1){
// 等待事件发生
int nready = epoll_wait(reactor->epfd, events, epoll_events_size, -1);
if(nready == -1){
printf("epoll_wait error: %s\n", strerror(errno));
break;
}
...
for(i=0; i<nready; i++){
int connfd = events[i].data.fd;
zv_connect* conn = zv_connect_idx(reactor, connfd);
// 回调函数和下面的的逻辑实现了数据回环
if(EPOLLIN & events[i].events){
conn->cb(connfd, events[i].events, reactor);
}
if(EPOLLOUT & events[i].events){
conn->cb(connfd, events[i].events, reactor);
}
}
}
十、关于输入的键值对
1. 为什么 tokens[1]
代表键 (key
),tokens[2]
代表值 (value
):
-
命令解析:在许多命令行工具中,一个命令会被拆分成多个部分(即“tokens”)。通常,
tokens[0]
保存命令名,而后面的tokens
则保存命令的参数。 -
键值对命令:在一个键值对存储的
set
命令中,命令格式可能是set key value
,其中:-
tokens[0]
是"set"
, -
tokens[1]
是key
(键), -
tokens[2]
是value
(值)。
-
-
在这个上下文中,当你调用
kv_array_set
时,tokens[1]
就表示你想要存储的键,tokens[2]
则表示与该键相关联的值。
2. 为什么使用 strlen(tokens[1])+1
和 strlen(tokens[2])+1
:
-
内存分配:当你为一个字符串分配内存时,需要考虑到字符串末尾的空字符(
\0
)。 -
字符串长度:
strlen(tokens[1])
返回的是tokens[1]
中字符串的长度,但这个长度不包括空字符。 -
加1以包含空字符:为了正确地分配内存,你需要为字符串本身加上一个额外的字节,以容纳空字符。这就是为什么使用
malloc(strlen(tokens[1])+1)
和strncpy(kcopy, tokens[1], strlen(tokens[1])+1)
。