6、接受连接:accept()函数详解、阻塞与非阻塞、获取客户端地址信息
好,咱们继续往下走。上一节我们把服务器绑到了端口上,开始监听。但监听归监听,真正跟客户端“握手”建立连接的,是今天要聊的 accept() 函数。
说实话,accept() 是我早期写网络程序时踩坑最多的一个点。你以为它只是“接个电话”那么简单?其实里面门道不少。阻塞与非阻塞怎么选?客户端地址怎么拿?拿到的 IP 和端口对不对?嗯,咱们一个一个说清楚。
accept() 的基本用法
先看函数原型:
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数解释:
- sockfd:就是之前
listen()的那个监听套接字。注意,不是新连接的套接字。 - addr:指向一个
struct sockaddr结构体的指针。用来存放客户端的地址信息。如果你不关心客户端是谁,可以传 NULL。 - addrlen:一个值-结果参数。调用前你要把
addr结构体的大小填进去,调用后内核会告诉你实际用了多少。
返回值:成功返回一个新的套接字描述符,专门用于和这个客户端通信。失败返回 -1,并设置 errno。
核心理解:accept() 从已完成连接队列中取出一个连接,并为它创建一个新的套接字。原来的监听套接字继续监听,不受影响。
阻塞模式 vs 非阻塞模式
这是 accept() 最让人纠结的地方。我刚开始写的时候,总觉得阻塞模式简单,非阻塞模式复杂。后来被坑了几次才明白——没有绝对的好坏,看场景。
阻塞模式
默认情况下,accept() 是阻塞的。什么意思?
如果已完成连接队列是空的,accept() 就会一直等在那里,直到有新的客户端连接进来。就像你坐在电话旁等电话,电话铃不响,你就一直坐着。
int client_fd = accept(server_fd, NULL, NULL);
// 程序停在这里,直到有客户端连接
printf("新客户端连接上了!\n");
这种模式适合简单的、单线程的服务器。比如你写个测试程序,或者客户端数量很少的场景。我个人习惯在快速原型阶段用阻塞模式,省事。
非阻塞模式
非阻塞模式下,accept() 会立即返回。如果队列里有连接,就返回新套接字;如果没有,就返回 -1,并且 errno 被设置为 EAGAIN 或 EWOULDBLOCK。
设置非阻塞有两种方式:
- 创建套接字时用
SOCK_NONBLOCK标志:socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0) - 创建后用
fcntl()设置:fcntl(sockfd, F_SETFL, O_NONBLOCK)
// 方式一:创建时设置
int server_fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
// 方式二:创建后设置
int flags = fcntl(server_fd, F_GETFL, 0);
fcntl(server_fd, F_SETFL, flags | O_NONBLOCK);
非阻塞模式通常配合 select()、poll() 或 epoll() 使用。你想想看,一个高并发服务器,如果每个连接都阻塞着等,那还怎么处理成千上万的客户端?
我曾经踩过的坑:有一次我写了一个非阻塞的 accept 循环,忘了处理 EAGAIN 错误。结果 CPU 占用率直接飙到 100%,因为循环一直在空转。记住,非阻塞模式下一定要检查 errno,并且适当休眠或让出 CPU。
获取客户端地址信息
很多时候,我们需要知道是谁连上来了。比如记录日志、做访问控制、或者根据客户端 IP 做负载均衡。
这时候 accept() 的第二个和第三个参数就派上用场了。
struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr);
int client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &addr_len);
if (client_fd == -1) {
perror("accept");
return -1;
}
// 获取客户端 IP 和端口
char client_ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, sizeof(client_ip));
int client_port = ntohs(client_addr.sin_port);
printf("客户端连接:%s:%d\n", client_ip, client_port);
这里有几个细节要注意:
addr_len一定要先初始化为sizeof(struct sockaddr_in)。我见过有人忘了初始化,结果accept()返回 -1,查了半天才发现是这里的问题。- 用
inet_ntop()把二进制 IP 地址转成可读的字符串。别用inet_ntoa(),那个函数不是线程安全的。 - 端口号要用
ntohs()转换,因为网络字节序是大端,而我们的主机可能是小端。
完整的 accept 示例
下面是一个完整的示例,展示了阻塞模式下如何接受连接并获取客户端信息:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
int main() {
// 1. 创建套接字
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1) {
perror("socket");
exit(EXIT_FAILURE);
}
// 2. 绑定地址
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(8888);
if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
perror("bind");
close(server_fd);
exit(EXIT_FAILURE);
}
// 3. 开始监听
if (listen(server_fd, 5) == -1) {
perror("listen");
close(server_fd);
exit(EXIT_FAILURE);
}
printf("服务器已启动,监听端口 8888...\n");
// 4. 接受连接
struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr);
int client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &addr_len);
if (client_fd == -1) {
perror("accept");
close(server_fd);
exit(EXIT_FAILURE);
}
// 5. 打印客户端信息
char client_ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, sizeof(client_ip));
printf("新客户端连接:%s:%d\n", client_ip, ntohs(client_addr.sin_port));
// 6. 关闭连接
close(client_fd);
close(server_fd);
return 0;
}
小技巧:如果你用 accept() 获取客户端地址时传了 NULL,后来又想知道客户端是谁,可以用 getpeername() 函数。它可以从已连接的套接字中获取对端地址。不过既然 accept() 能直接拿到,何必多此一举呢?
accept() 的常见错误
| 错误码 | 含义 | 常见原因 |
|---|---|---|
EAGAIN / EWOULDBLOCK |
非阻塞模式下,没有新连接 | 队列为空,需要等待 |
EBADF |
套接字描述符无效 | 传入了错误的 fd,或者 fd 已经被关闭 |
EINTR |
被信号中断 | 进程收到了信号,需要重新调用 accept |
EMFILE |
进程文件描述符已满 | 打开的文件太多,超过了系统限制 |
ENOTSOCK |
描述符不是套接字 | 传入了文件描述符而不是套接字描述符 |
我曾经踩过的坑:有一次线上服务器突然不接受新连接了,查了半天发现是 EMFILE 错误。原来有个客户端连接后没有正常关闭,导致文件描述符泄漏。从那以后,我每次写 accept 循环都会检查 EMFILE,并做相应的处理——比如暂时关闭一些空闲连接,或者记录日志报警。
知识体系图
下面这张图总结了 accept() 的核心逻辑和整个连接建立流程:
总结一下
accept() 是服务器编程中承上启下的关键函数。它从监听套接字中取出一个已完成三次握手的连接,返回一个新的套接字用于通信。
阻塞模式简单直接,适合低并发场景。非阻塞模式配合 I/O 多路复用,是高并发服务器的基石。获取客户端地址时,记得初始化 addr_len,用 inet_ntop() 和 ntohs() 做正确的格式转换。
嗯,关于 accept() 就聊这么多。下一节我们会深入探讨如何用 select() 同时管理多个连接,那才是真正迈向高并发服务器的第一步。