Socket 编程基础:类型、生命周期与模式

Socket 编程,说白了就是让两台机器能互相说话。我做了这么多年高并发系统,见过太多人栽在 Socket 的基础概念上。今天咱们就把这块地基打牢。

Socket 类型:流式 vs 数据报

Socket 分两种,就像寄信和打电话的区别。我个人习惯用一张表来对比,清晰明了:

特性 流式 Socket (SOCK_STREAM) 数据报 Socket (SOCK_DGRAM)
底层协议 TCP UDP
连接方式 面向连接,必须先建立连接 无连接,直接发数据
数据边界 无边界,像水流一样连续 有边界,每个报文独立
可靠性 可靠,保证顺序和完整 不可靠,可能丢包或乱序
典型场景 HTTP、FTP、数据库连接 DNS、视频流、游戏状态同步

你想想看,流式 Socket 就像打电话——你先拨号,对方接听,然后你们才能说话。数据报 Socket 就像寄明信片——你写好地址扔邮筒里,对方能不能收到、什么时候收到,你没法保证。

我的经验: 做即时通讯系统时,我一开始全用了 TCP。后来发现心跳包和位置上报用 UDP 更合适——丢几个包无所谓,但延迟必须低。别死磕一种类型,看场景选。

Socket 生命周期:从生到死

一个 Socket 从创建到关闭,要经历五个阶段。我在项目中遇到过不少问题,都是因为对生命周期理解不透彻导致的。

1. 创建(socket)

这是第一步,告诉系统你要用哪种协议族和类型:

int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
    perror("socket creation failed");
    exit(EXIT_FAILURE);
}

嗯,这里要注意:AF_INET 是 IPv4,SOCK_STREAM 是 TCP。第三个参数 0 表示让系统自动选择协议。我见过有人传了个乱七八糟的数字进去,结果 socket 创建失败还不知道为什么。

2. 绑定(bind)

把 Socket 和本地地址、端口绑在一起。服务端必须做这一步:

struct sockaddr_in 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 failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}
我曾经踩过的坑: 端口被占用时 bind 会失败。更隐蔽的是,程序崩溃后端口还处于 TIME_WAIT 状态,重启时 bind 报错。解决方案是用 setsockopt 设置 SO_REUSEADDR。

3. 监听(listen)

告诉内核:我准备好了,可以接受连接请求了。这里有个关键参数——backlog:

if (listen(sockfd, 10) < 0) {
    perror("listen failed");
    exit(EXIT_FAILURE);
}

backlog 是连接请求队列的最大长度。你想想看,如果同时有 100 个客户端请求连接,而 backlog 只设了 5,那后面 95 个就会被内核直接拒绝。高并发场景下,这个值要设大一些,但也不是越大越好——每个等待的连接都要占用内核资源。

4. 连接(connect / accept)

这是两端的事。客户端调用 connect,服务端调用 accept:

// 客户端
if (connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
    perror("connect failed");
    exit(EXIT_FAILURE);
}

// 服务端
int client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &addr_len);
if (client_fd < 0) {
    perror("accept failed");
    continue;  // 别 exit,继续接受下一个
}

这里有个细节:accept 返回的是一个新的 Socket 描述符。原来的监听 Socket 还在继续监听,新的 Socket 才用来和客户端通信。我刚开始做的时候以为 accept 之后原来的 Socket 就不能用了,其实不是。

5. 关闭(close)

通信结束,释放资源:

close(client_fd);  // 关闭连接 Socket
close(server_fd);  // 关闭监听 Socket
关键点: 关闭 Socket 时,内核会发送 FIN 包开始四次挥手。如果还有数据没发完,调用 shutdown 比 close 更可控——它可以只关闭写方向,保留读方向继续接收剩余数据。

阻塞与非阻塞模式

这是 Socket 编程里最容易让人困惑的地方。说白了,阻塞就是「等」,非阻塞就是「不等」。

阻塞模式

默认情况下,Socket 是阻塞的。你调用 recv,如果没有数据,线程就卡在那里等:

char buf[1024];
int n = recv(sockfd, buf, sizeof(buf), 0);
// 如果没有数据,这行代码永远不会执行

阻塞模式的好处是编程简单,逻辑清晰。坏处是——一个线程只能处理一个 Socket。如果这个 Socket 一直没数据,线程就白白浪费了。

非阻塞模式

设置非阻塞后,recv 会立即返回。有数据就返回数据长度,没数据就返回 -1,并设置 errno 为 EAGAIN 或 EWOULDBLOCK:

// 设置非阻塞
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);

// 非阻塞读取
char buf[1024];
int n = recv(sockfd, buf, sizeof(buf), 0);
if (n < 0) {
    if (errno == EAGAIN || errno == EWOULDBLOCK) {
        // 没有数据,去做别的事
        printf("no data now, do other work\n");
    } else {
        perror("recv error");
    }
}

你想想看,非阻塞模式让一个线程可以同时处理成百上千个 Socket。但代价是代码变复杂了——你得不断轮询,或者用 select、epoll 来管理。

对比项 阻塞模式 非阻塞模式
编程复杂度 低,逻辑直观 高,需要状态管理
CPU 利用率 低(线程休眠等待) 高(需要轮询或事件驱动)
并发能力 低(一个线程一个连接) 高(一个线程多个连接)
典型场景 简单客户端、低并发服务 高并发服务器、网关
我的建议: 新手先从阻塞模式入手,把生命周期和基本 API 跑通。等理解了「为什么阻塞模式撑不住高并发」,再切换到非阻塞 + epoll。别一上来就搞非阻塞,容易把自己绕晕。

知识体系总览

下面这张图把本章的核心知识点串起来了。我习惯用这种图来梳理思路,你也试试看:

Socket 编程基础:知识体系 Socket 类型 流式 Socket (SOCK_STREAM / TCP) 数据报 Socket (SOCK_DGRAM / UDP) Socket 生命周期 ① 创建 socket ② 绑定 bind ③ 监听 listen ④ 连接 connect/accept ⑤ 关闭 close 阻塞与非阻塞模式 阻塞模式:等数据,简单但低效 非阻塞模式:不等数据,复杂但高效 核心基础

这张图把 Socket 类型、生命周期五阶段、阻塞与非阻塞模式串在了一起。你仔细看,生命周期是中间的主干,类型决定了你用什么协议,模式决定了你怎么处理 I/O。这三者组合起来,就是 Socket 编程的全部基础。

一句话总结: 选对类型(TCP/UDP),走对生命周期(创建→绑定→监听→连接→关闭),用对模式(阻塞/非阻塞)。这三件事搞定了,Socket 编程你就入门了。