75、网络编程基础:socket、bind、listen、accept

网络编程,说白了就是让两台机器能互相说话。很多初学者一上来就被各种API搞晕了,觉得特别抽象。我当年刚接触时也一样,对着man page看了半天,脑子里还是一团浆糊。后来亲手写了个聊天程序,才真正明白这些函数到底在干什么。

今天咱们就把socket、bind、listen、accept这四个函数彻底讲透。你想想看,一个服务器要接受客户端的连接,本质上就四步:拿个电话机、绑上号码、开机等着、有人打进来就接。嗯,就是这么回事。

1. socket——拿个电话机

socket函数的作用,就是创建一个通信端点。你可以把它理解成「拿一个电话机」。电话机有不同型号,socket也有不同类型。

#include <sys/socket.h>
int socket(int domain, int type, int protocol);

三个参数分别指定:

  • domain:协议族。最常用的是AF_INET(IPv4)和AF_INET6(IPv6)。
  • type:套接字类型。SOCK_STREAM是流式套接字(TCP),SOCK_DGRAM是数据报套接字(UDP)。
  • protocol:具体协议。一般传0,让系统自动选择。
我的习惯:写TCP服务器时,我通常直接写socket(AF_INET, SOCK_STREAM, 0)。第三个参数传0,系统会自动匹配TCP协议。省事,也不容易出错。

返回值是一个文件描述符。如果返回-1,说明创建失败。我在项目中遇到过有人忘了检查返回值,结果后面bind到一个无效描述符上,排查了半天才发现是socket创建时资源不够了。

2. bind——绑上号码

有了电话机,你得给它分配一个号码,别人才能打进来。bind函数就是把一个本地地址绑定到socket上。

#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

这里有个坑:struct sockaddr是个通用结构体,实际使用时我们一般用struct sockaddr_in(IPv4)或struct sockaddr_in6(IPv6),然后强制类型转换。为什么这么设计?说白了就是历史遗留问题,早期C语言没有void指针,只能这么搞。

struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);  // 监听所有网卡
serv_addr.sin_port = htons(8888);               // 端口号

bind(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
注意字节序:网络字节序是大端模式。x86机器是小端,所以要用htonlhtons转换。我曾经见过有人直接写端口号,结果bind成功但客户端死活连不上——因为端口被解释成了另一个值。

bind失败常见原因:端口被占用、权限不够(绑定1024以下端口需要root)、地址已被使用但未设置SO_REUSEADDR选项。

3. listen——开机等着

bind之后,socket还处于「待机」状态。你需要调用listen,告诉内核:这个socket要作为服务器,准备接受连接请求。

#include <sys/socket.h>
int listen(int sockfd, int backlog);

backlog参数指定连接请求队列的最大长度。什么意思呢?当多个客户端同时发起连接时,内核会维护一个队列。backlog就是这个队列的上限。

我个人的建议:backlog不要设太大,也不要太小。一般设5到128之间。设太大浪费内存,设太小在高并发时容易丢连接。Linux内核实际会取这个值和/proc/sys/net/core/somaxconn的较小值。

关键点:listen只是让socket进入监听状态,它并不阻塞。调用后立即返回,真正的阻塞发生在accept那里。

4. accept——有人打进来就接

这是最核心的一步。accept从已完成连接队列中取出一个连接,并返回一个新的socket描述符。这个新描述符才是真正用来和客户端通信的。

#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

第二个参数用来获取客户端的地址信息,如果不需要可以传NULL。第三个参数是地址结构体长度的指针,传入时指定缓冲区大小,返回时被设置为实际地址长度。

struct sockaddr_in cli_addr;
socklen_t cli_len = sizeof(cli_addr);
int connfd = accept(listenfd, (struct sockaddr *)&cli_addr, &cli_len);
if (connfd < 0) {
    perror("accept error");
    exit(1);
}

这里有个容易忽略的点:accept返回的connfd和原来的listenfd是两回事。listenfd继续监听新的连接,connfd专门和这个客户端通信。我见过有人搞混了,拿着listenfd去收发数据,结果一直报错。

完整流程与SVG图解

下面这张图把整个流程串起来了。从socket创建,到bind绑定,到listen监听,再到accept接受连接,最后用新描述符通信。每一步都清清楚楚。

TCP服务器建立连接完整流程 1. socket() 创建套接字 2. bind() 绑定地址和端口 3. listen() 进入监听状态 4. accept() 接受客户端连接 5. read() / write() 使用新描述符通信 6. close() 关闭 关键说明 • listenfd 持续监听新连接 • accept 返回新描述符 connfd • connfd 专用于和客户端通信

常见错误与避坑指南

写网络程序,坑是真的多。我把自己踩过的和帮别人排查过的常见问题列出来,你写代码时多留个心眼。

错误 现象 原因 解决方法
bind: Address already in use bind返回-1,errno为EADDRINUSE 端口被占用,或上次程序退出后未释放 设置SO_REUSEADDR选项
accept返回-1 客户端连接不上 listenfd无效、队列满、被信号中断 检查listenfd,处理EINTR
客户端连接成功但无法通信 read/write返回-1 用了listenfd而不是connfd 确认使用accept返回的描述符
端口号不对 客户端连不上服务器 bind时未用htons转换端口 使用htons()转换端口号

我曾经踩过的坑:有一次写一个压力测试程序,客户端疯狂发起连接,结果accept返回了-1。查了半天,发现是backlog设得太小,连接队列满了。后来我把backlog调大,同时加了重试机制,问题就解决了。

一个完整的TCP服务器骨架

说了这么多,不如直接看代码。下面是一个最简的TCP服务器框架,你可以在它的基础上扩展。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>

#define PORT 8888
#define BACKLOG 5

int main() {
    int listenfd, connfd;
    struct sockaddr_in serv_addr, cli_addr;
    socklen_t cli_len;

    // 1. 创建socket
    listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenfd < 0) {
        perror("socket");
        exit(1);
    }

    // 设置SO_REUSEADDR,避免端口被占用
    int opt = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    // 2. 绑定地址
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_addr.sin_port = htons(PORT);

    if (bind(listenfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
        perror("bind");
        close(listenfd);
        exit(1);
    }

    // 3. 监听
    if (listen(listenfd, BACKLOG) < 0) {
        perror("listen");
        close(listenfd);
        exit(1);
    }

    printf("Server listening on port %d...\n", PORT);

    // 4. 接受连接
    cli_len = sizeof(cli_addr);
    connfd = accept(listenfd, (struct sockaddr *)&cli_addr, &cli_len);
    if (connfd < 0) {
        perror("accept");
        close(listenfd);
        exit(1);
    }

    printf("Client connected!\n");

    // 5. 通信(这里只做演示,实际需要循环处理)
    char buf[1024];
    read(connfd, buf, sizeof(buf));
    printf("Received: %s\n", buf);

    // 6. 关闭
    close(connfd);
    close(listenfd);
    return 0;
}

我的建议:实际项目中,accept之后通常会fork一个子进程来处理通信,或者用select/epoll实现多路复用。单进程串行处理只能用于教学演示,千万别用在生产环境。

好了,socket、bind、listen、accept这四个函数,咱们今天就讲到这里。你想想看,其实网络编程没那么神秘,就是按照流程一步步来。多写几遍,自然就熟了。


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