从Socket编程理解TLS连接本质 - 第3节

Socket 是什么?一个朴素的比喻

聊网络编程,绕不开 Socket。很多新手觉得它很玄乎,其实说白了,Socket 就是操作系统提供的一个编程接口。你想想看,两台机器要通信,中间隔着网线、路由器、各种协议栈,难道让你直接操作网卡寄存器?当然不行。

操作系统把网络通信的细节封装了,给你扔出来一个「文件描述符」。你往这个描述符里写数据,数据就通过网络发出去;别人发来的数据,你也从这个描述符里读。就这么简单。

我个人习惯把 Socket 理解成「网络世界的文件句柄」。读写 Socket 和读写本地文件,在 API 层面几乎一样——open、read、write、close。只不过文件的数据在磁盘上,Socket 的数据在网络上。

一句话总结:Socket 是应用层与传输层之间的门。你不需要知道门后面怎么走,你只需要知道怎么敲门、怎么递东西。

TCP Socket 的 C/S 模型

TCP 是面向连接的协议,所以它的 Socket 模型天然就是客户端-服务器架构。服务端得先跑起来,等着别人连;客户端主动发起连接。这个模型很成熟,几乎所有互联网应用都在用。

服务端:四步走

服务端的代码流程,我拆成四个核心步骤。嗯,这里要注意,每一步都有坑。

  1. 创建 Socket —— 调用 socket(),指定协议族和类型。
  2. 绑定地址 —— 调用 bind(),把 Socket 和 IP、端口绑在一起。
  3. 监听 —— 调用 listen(),告诉内核:我准备好了,可以接受连接了。
  4. 接受连接 —— 调用 accept(),阻塞等待客户端上门。

我曾经在 bind 这一步吃过亏。端口被占用了,bind 直接返回 -1,程序崩溃。后来我养成了一个习惯:每次 bind 之前先设置 SO_REUSEADDR 选项,这样即使端口还在 TIME_WAIT 状态,也能重新绑定。

客户端:三步走

客户端更简单,三步搞定:

  1. 创建 Socket —— 同样 socket()。
  2. 连接服务端 —— 调用 connect(),传入服务端的 IP 和端口。
  3. 收发数据 —— 用 send() 和 recv() 干活。

你想想看,客户端不需要 bind,也不需要 listen。它只需要知道服务端在哪,然后敲门就行。内核会自动给客户端的 Socket 分配一个临时端口。

核心 API 速查表

我把最常用的几个 API 整理成了一张表。这张表我建议你收藏,写代码时经常回来翻翻。

函数 作用 关键参数 返回值
socket() 创建 Socket AF_INET, SOCK_STREAM, 0 文件描述符 / -1
bind() 绑定地址和端口 sockfd, struct sockaddr*, addrlen 0 / -1
listen() 进入监听状态 sockfd, backlog 0 / -1
accept() 接受客户端连接 sockfd, struct sockaddr*, socklen_t* 新 fd / -1
connect() 连接服务端 sockfd, struct sockaddr*, addrlen 0 / -1
send() 发送数据 sockfd, buf, len, flags 发送字节数 / -1
recv() 接收数据 sockfd, buf, len, flags 接收字节数 / 0 / -1
close() 关闭 Socket sockfd 0 / -1
小技巧:recv() 返回 0 表示对方已经优雅关闭连接。返回 -1 才是出错。很多新手把 0 当成错误处理,结果漏掉了正常的连接关闭事件。

一个完整的 C/S 示例

光说不练假把式。我写一个最简单的回声服务器(Echo Server),客户端发什么,服务端原样返回什么。代码我尽量精简,去掉错误处理,让你看清骨架。

服务端代码

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

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

    // 1. 创建 Socket
    server_fd = socket(AF_INET, SOCK_STREAM, 0);

    // 2. 绑定地址
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;  // 监听所有网卡
    addr.sin_port = htons(8888);
    bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));

    // 3. 监听
    listen(server_fd, 5);

    // 4. 接受连接
    client_fd = accept(server_fd, NULL, NULL);

    // 5. 收发数据
    int n = recv(client_fd, buf, sizeof(buf), 0);
    send(client_fd, buf, n, 0);

    close(client_fd);
    close(server_fd);
    return 0;
}

客户端代码

// client.c
#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>

int main() {
    int sock_fd;
    struct sockaddr_in addr;
    char *msg = "Hello, Socket!";
    char buf[1024];

    // 1. 创建 Socket
    sock_fd = socket(AF_INET, SOCK_STREAM, 0);

    // 2. 连接服务端
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = inet_addr("127.0.0.1");
    addr.sin_port = htons(8888);
    connect(sock_fd, (struct sockaddr*)&addr, sizeof(addr));

    // 3. 发送并接收
    send(sock_fd, msg, strlen(msg), 0);
    int n = recv(sock_fd, buf, sizeof(buf), 0);
    buf[n] = '\0';
    printf("收到: %s\n", buf);

    close(sock_fd);
    return 0;
}
注意:上面的代码为了展示核心流程,省略了所有错误检查。生产环境绝对不能这么写。每个系统调用都要检查返回值,否则一个网络抖动就能让你的程序崩溃。

TCP 三次握手与 Socket API 的对应关系

很多教材讲三次握手,但从来不告诉你它和 Socket API 怎么对应。我当年学的时候也困惑过:三次握手到底发生在哪个函数里?

其实很简单:

  • 第一次握手(SYN):客户端调用 connect() 时触发。内核自动发 SYN 包。
  • 第二次握手(SYN+ACK):服务端内核收到 SYN,自动回复 SYN+ACK。此时服务端的 accept() 还没返回。
  • 第三次握手(ACK):客户端内核收到 SYN+ACK,自动发 ACK。此时 connect() 返回成功。

你发现没有?三次握手完全由内核自动完成,应用层根本不需要插手。connect() 返回时,连接已经建立好了。accept() 返回时,服务端拿到的是已经完成三次握手的连接。

核心认知:三次握手是传输层的事,Socket API 只是把结果告诉应用层。你写应用代码时,不需要关心 SYN、ACK 这些细节,但理解这个对应关系能帮你排查很多连接问题。

TCP Socket 核心流程全景图

下面这张图,我画了 TCP Socket 从创建到关闭的完整流程。左边是服务端,右边是客户端,中间是三次握手和四次挥手。你盯着看两分钟,整个模型就刻在脑子里了。

TCP Socket C/S 模型完整流程 服务端: socket() 服务端: bind() 服务端: listen() 服务端: accept() 阻塞 客户端: socket() 客户端: connect() SYN SYN+ACK ACK accept() 返回新 fd connect() 返回成功 TCP 连接已建立 recv() / send() send() / recv() 数据流 close() close() FIN FIN+ACK ACK TCP 连接已关闭

几个常见的坑

做网络编程,有些坑是绕不过去的。我踩过,也看别人踩过。列几个最常见的:

  • 端口被占用:服务端崩溃后立即重启,bind 失败。因为端口还在 TIME_WAIT 状态。解决方案:设置 SO_REUSEADDR。
  • recv 不保证一次收完:TCP 是流协议,没有消息边界。你发 100 字节,对方可能分两次收到。我曾经在做一个网关时,就因为没处理粘包,导致协议解析全乱套了。
  • 阻塞与非阻塞:默认 Socket 是阻塞的。accept、recv、send 都可能卡住。生产环境一般用非阻塞 + epoll/select 多路复用。
  • 惊群问题:多线程/多进程同时 accept 同一个监听 Socket,内核会唤醒所有等待者,但只有一个能成功。高并发场景下这是个性能杀手。
我的建议:刚开始学的时候,先用阻塞式单线程把流程跑通。别一上来就上 epoll、libevent 那些东西。先把基础模型刻在脑子里,后面再优化不迟。

小结

Socket 编程的核心就这些东西。一个 Socket 描述符,几行 API 调用,一个 C/S 模型。你把这个模型理解透了,后面学 TLS 就轻松很多——因为 TLS 本质上就是在 TCP Socket 之上加了一层加密层。连接怎么建立、数据怎么收发、连接怎么关闭,这些骨架完全一样。

嗯,这一章就到这。代码你最好自己敲一遍,别光看。敲完跑起来,看到 "Hello, Socket!" 打印出来,那种感觉是不一样的。