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 被设置为 EAGAINEWOULDBLOCK

设置非阻塞有两种方式:

  1. 创建套接字时用 SOCK_NONBLOCK 标志:socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0)
  2. 创建后用 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() 核心逻辑与连接建立流程 socket() bind() listen() accept() 阻塞/非阻塞 recv()/send() socket() connect() TCP 三次握手 send()/recv() 获取客户端地址 struct sockaddr_in 阻塞 vs 非阻塞

总结一下

accept() 是服务器编程中承上启下的关键函数。它从监听套接字中取出一个已完成三次握手的连接,返回一个新的套接字用于通信。

阻塞模式简单直接,适合低并发场景。非阻塞模式配合 I/O 多路复用,是高并发服务器的基石。获取客户端地址时,记得初始化 addr_len,用 inet_ntop()ntohs() 做正确的格式转换。

嗯,关于 accept() 就聊这么多。下一节我们会深入探讨如何用 select() 同时管理多个连接,那才是真正迈向高并发服务器的第一步。


公众号:蓝海资料掘金营,微信deep3321