1. Socket 基础概念:什么是 Socket、Socket 与 TCP/IP 的关系、Socket 通信模型

做 SRE 这些年,我处理过不少 Socket 相关的问题。说实话,很多故障的根因,最后都绕回到对 Socket 基础的理解上。这一节,我们就先把地基打牢。

1.1 什么是 Socket?

Socket,中文叫「套接字」。说白了,它就是操作系统提供的一个编程接口,让两个程序能在网络上通信。你想想看,网络通信的细节其实很复杂——数据怎么分包、怎么路由、怎么保证不丢包……这些底层逻辑全在内核里。而 Socket 就是内核给你的一扇门,你只要打开这扇门,往里扔数据,或者从里面取数据,剩下的内核帮你搞定。

我个人习惯把 Socket 理解成「网络世界的文件描述符」。为什么这么说?因为在 Linux 里,Socket 和文件的操作方式几乎一样——你用 socket() 创建,用 read()/write() 读写,最后用 close() 关闭。这种设计让程序员上手非常快。

核心要点:Socket 是应用层与传输层之间的抽象层。它屏蔽了网络协议的复杂性,让开发者只需要关心「发什么」和「收什么」。

1.2 Socket 与 TCP/IP 的关系

这个问题我经常被问到。很多人搞不清 Socket 和 TCP/IP 到底谁是谁。其实很简单:TCP/IP 是一套协议栈,而 Socket 是这套协议栈的 API 封装。

举个例子。TCP/IP 协议栈从上到下分四层:应用层、传输层、网络层、链路层。Socket 就坐在应用层和传输层之间。当你调用 send() 时,Socket 把数据交给 TCP 协议,TCP 负责分段、重传、流量控制,然后交给 IP 层去路由,最后通过网卡发出去。

我记得有一次排查线上延迟问题,发现应用层调用 send() 后迟迟没有返回。当时我第一反应是网络问题,结果抓包一看,数据根本没出本机。最后定位到是 Socket 发送缓冲区满了,TCP 的流量控制机制在起作用。你看,不理解 Socket 和 TCP 的关系,这种问题根本无从下手。

层次 职责 Socket 的角色
应用层 HTTP、FTP、自定义协议 通过 Socket API 收发数据
传输层 TCP/UDP 协议 Socket 绑定端口,管理连接状态
网络层 IP 协议,路由寻址 Socket 不直接参与,由内核处理
链路层 MAC 地址,物理传输 Socket 完全透明

一个小技巧:当你用 ss -tlnp 查看 Socket 状态时,看到的 ESTAB、TIME_WAIT 这些状态,其实是 TCP 协议的状态机在 Socket 层面的映射。搞懂 TCP 状态迁移图,排查 Socket 问题就成功了一半。

1.3 Socket 通信模型

Socket 通信模型,说白了就是「谁等谁、谁找谁」。最经典的模型是客户端-服务器模型,也叫 C/S 模型。我画了一张图,你看一眼就明白了。

Socket 通信模型流程图 Socket 通信模型(TCP 客户端-服务器) 服务器:socket() 创建 bind() 绑定端口 listen() 开始监听 accept() 等待连接 客户端:socket() 创建 connect() 发起连接 三次握手 read() / write() 通信 read() / write() 通信 全双工通信 close() 关闭 close() 关闭 说明:服务器先启动,调用 accept() 阻塞等待;客户端调用 connect() 触发三次握手; 连接建立后双方可以全双工通信;任意一方调用 close() 即开始四次挥手断开连接。

这个模型里,服务器是「被动方」,客户端是「主动方」。服务器先启动,创建 Socket,绑定端口,然后进入监听状态。客户端后启动,创建 Socket,然后主动去连接服务器。连接建立后,两边就可以互相发数据了。

这里有个细节很多人会忽略:服务器端的 accept() 会返回一个新的 Socket 描述符。原来的监听 Socket 继续等新连接,新 Socket 专门处理这个客户端的通信。我见过不少新手写服务器代码时,直接用监听 Socket 收发数据,结果导致后续连接全部失败。

我曾经踩过的坑:有一次线上服务突然拒绝连接,排查发现是监听 Socket 的 backlog 队列满了。backlog 是 listen() 的第二个参数,控制未完成三次握手的连接队列长度。默认值通常只有 128,在高并发场景下很容易被打满。后来我把它调到了 1024,问题解决。

1.4 Socket 的类型

Socket 不只有 TCP 这一种。根据传输层协议的不同,主要分三类:

  • 流式 Socket(SOCK_STREAM):基于 TCP,面向连接,可靠,有序。适合 HTTP、数据库连接等场景。
  • 数据报 Socket(SOCK_DGRAM):基于 UDP,无连接,不可靠,但速度快。适合 DNS 查询、视频流等场景。
  • 原始 Socket(SOCK_RAW):直接操作 IP 层,可以自定义协议头。一般用于网络诊断工具,比如 ping 就是基于原始 Socket 实现的。

我个人在 SRE 工作中,90% 的问题都出在流式 Socket 上。UDP 的问题相对少,但一旦出问题往往更难排查,因为无连接的特性导致你很难追踪状态。

1.5 一个简单的代码示例

光说不练假把式。下面是一个最简的 TCP 服务器和客户端代码,帮你把上面的流程串起来。

// 服务器端(server.c)
#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>

int main() {
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(8080);
    addr.sin_addr.s_addr = INADDR_ANY;
    
    bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(server_fd, 128);
    
    int client_fd = accept(server_fd, NULL, NULL);
    // 这里就可以用 client_fd 收发数据了
    close(client_fd);
    close(server_fd);
    return 0;
}
// 客户端(client.c)
#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>

int main() {
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(8080);
    inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
    
    connect(sock, (struct sockaddr*)&addr, sizeof(addr));
    // 连接建立成功,可以收发数据了
    close(sock);
    return 0;
}

生产环境小贴士:上面的代码为了演示去掉了错误处理。但在生产环境里,socket()、bind()、connect() 每一步都可能失败。我建议你养成习惯:每次系统调用后都检查返回值,至少打一条日志。很多线上故障,就是因为忽略了这些返回值导致的。