从零掌握端口复用机制 - 第3节

Socket 是什么?先别急着背定义

很多人一上来就背「Socket 是网络通信的端点」,背完还是不会用。我换个说法——Socket 就是操作系统给你的一根网线插口。你写网络程序,本质上就是在这根插口上收发数据。

我记得刚入行那会儿,带我的老工程师说了句话,我一直记着:「Socket 就是文件描述符的变种」。你想想看,Linux 里一切皆文件。你打开一个普通文件,得到一个 fd;你创建一个 Socket,得到的也是一个 fd。只不过这个 fd 背后不是硬盘上的数据块,而是网络协议栈里的收发缓冲区。

一句话总结:Socket = 文件描述符 + 网络协议栈。你读写 Socket 就像读写文件,只不过数据跑到了网络上。

Socket 类型:流式 vs 数据报

Socket 分两种,说白了就是打电话和寄快递的区别。

流式 Socket(SOCK_STREAM)

对应 TCP。特点就三个字:稳、序、重。

  • 稳:数据保证到达,丢包会自动重传
  • 序:先发的数据先到,不会乱序
  • 重:没有消息边界,你发 100 字节,对端可能分两次收 50+50,也可能一次收 100

我在项目中遇到过最典型的坑就是「粘包」。客户端连续发了两个包,服务端一次 recv 全收走了。这不是 TCP 的错,TCP 本来就是流,没有边界。解决方案?自己加消息头或者用固定分隔符。

数据报 Socket(SOCK_DGRAM)

对应 UDP。特点也三个字:快、乱、丢。

  • 快:没有连接建立,没有重传,发就完了
  • 乱:后发的包可能先到,顺序不保证
  • 丢:网络拥堵直接丢包,应用层不处理就没了

但数据报有个好处——有消息边界。你 sendto 发 100 字节,对端 recvfrom 一次就收 100 字节,不会出现粘包。我做过一个日志采集系统,用的就是 UDP,丢了几个包无所谓,但延迟必须低。

对比维度 流式 Socket(TCP) 数据报 Socket(UDP)
连接 面向连接,先建立连接再通信 无连接,直接发数据
可靠性 可靠,丢包重传 不可靠,丢包不通知
消息边界 无边界,流式传输 有边界,每个包独立
典型场景 HTTP、FTP、SSH DNS、视频直播、游戏

Socket API 概览:五个核心函数

写网络程序,你绕不开这五个函数。我按服务端启动的顺序讲,这样好理解。

socket() — 创建插口

int sockfd = socket(AF_INET, SOCK_STREAM, 0);

第一个参数指定协议族,IPv4 就用 AF_INET。第二个参数指定类型,SOCK_STREAM 是 TCP,SOCK_DGRAM 是 UDP。第三个参数一般填 0,让内核自动选协议。

返回值是一个文件描述符。如果返回 -1,说明创建失败。我曾经见过新手不检查返回值,直接拿 -1 去 bind,结果一脸懵。

注意:socket() 返回的 fd 默认是「主动」的,也就是客户端模式。要变成服务端,需要后续操作。

bind() — 绑定地址和端口

struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = INADDR_ANY;

bind(sockfd, (struct sockaddr *)&addr, sizeof(addr));

bind 就是把 Socket 和具体的 IP、端口绑在一起。不 bind 的话,内核会随机分配端口。服务端必须 bind,否则客户端不知道连哪里。

这里有个细节——htons() 是主机字节序转网络字节序。为什么要有这个?因为不同 CPU 的大小端不一样,网络协议统一用大端。我刚开始写代码时忘了调字节序,结果端口号对不上,排查了半天。

listen() — 开始监听

listen(sockfd, 5);

这个函数把 Socket 从「主动」变成「被动」。第二个参数是连接队列的长度,表示最多能积压多少个未处理的连接请求。

嗯,这里要注意:listen() 不阻塞。它只是告诉内核:「我准备好了,有连接进来就排队」。真正的等待发生在 accept()。

accept() — 接受连接

int client_fd = accept(sockfd, NULL, NULL);

accept() 从已完成连接队列里取出一个连接,返回一个新的 fd。这个新 fd 才是真正用来和客户端通信的。原来的监听 fd 继续等下一个连接。

我遇到过一个问题:高并发下 accept() 返回 -1,错误码是 EAGAIN。这是因为我把 Socket 设成了非阻塞,队列里没连接时直接返回。解决方案?要么轮询,要么用 epoll 等事件驱动。

connect() — 发起连接

connect(sockfd, (struct sockaddr *)&addr, sizeof(addr));

这是客户端用的。connect() 会发起三次握手,阻塞直到连接建立或超时。如果对端没在 listen,connect() 会返回 -1,错误码 ECONNREFUSED。

我曾经写过一个批量扫描工具,没设置 connect 超时,结果连一个不存在的 IP 卡了 30 秒。后来用非阻塞 connect + select 解决了。

一张图看懂 Socket 通信流程

下面这张图是我自己画的,把服务端和客户端的完整流程串起来。你对照着看,思路会清晰很多。

Socket 通信流程(TCP 服务端 vs 客户端) 服务端:socket() bind() — 绑定端口 listen() — 开始监听 accept() — 等待连接 recv() / send() — 通信 close() — 关闭 客户端:socket() connect() — 发起连接 send() / recv() — 通信 close() — 关闭 三次握手

我的建议:初学者先别急着看 epoll、多线程这些高级特性。把 socket → bind → listen → accept → recv/send → close 这条线跑通,后面学什么都快。

一个完整的 TCP 回显服务端示例

光说不练假把式。下面这个例子,你复制到 Linux 上就能跑。它做的事情很简单:客户端发什么,服务端原样返回。

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

int main() {
    int sockfd, client_fd;
    struct sockaddr_in addr;
    char buf[1024];
    int n;

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

    // 2. 绑定端口 8080
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_port = htons(8080);
    addr.sin_addr.s_addr = INADDR_ANY;

    if (bind(sockfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        perror("bind");
        return 1;
    }

    // 3. 开始监听
    if (listen(sockfd, 5) < 0) {
        perror("listen");
        return 1;
    }

    printf("服务端已启动,监听端口 8080...\n");

    // 4. 接受连接
    client_fd = accept(sockfd, NULL, NULL);
    if (client_fd < 0) {
        perror("accept");
        return 1;
    }

    // 5. 回显数据
    while ((n = recv(client_fd, buf, sizeof(buf), 0)) > 0) {
        send(client_fd, buf, n, 0);
    }

    // 6. 关闭
    close(client_fd);
    close(sockfd);
    return 0;
}

这个例子只处理一个客户端连接,生产环境肯定不够用。但作为理解 Socket 流程的起点,足够了。你可以在另一个终端用 telnet 127.0.0.1 8080 测试,输入什么就返回什么。

避坑:bind 时如果提示「Address already in use」,说明端口被占用了。可以用 setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, ...) 解决。我曾经在调试时频繁重启服务,没加这个选项,每次都要等 2 分钟才能重新绑定。