Socket 编程基础:从一根网线说起

说实话,我刚开始学网络编程那会儿,对 Socket 的理解特别模糊。总觉得它是个很玄乎的东西,像什么「套接字」——这翻译就让人摸不着头脑。后来踩了不少坑,才慢慢搞明白:Socket 其实就是操作系统提供的一个编程接口,让你能通过代码收发网络数据。

打个比方吧。你想跟远方的朋友打电话,得先有部电话机吧?Socket 就是那部电话机。你拨号、接通、说话、挂断,每一步操作系统都帮你封装好了。你只需要拿着这个「电话机」去操作就行。

核心一句话:Socket 是应用层与传输层之间的桥梁。它屏蔽了底层 TCP/IP 协议的复杂性,让你用 read/write 就能收发网络数据。

Socket 到底是什么?

从定义上讲,Socket 是一个文件描述符。对,你没看错——在 Linux 世界里,一切皆文件。网络连接也不例外。

我当年第一次看到这个说法时也很困惑:「网络连接怎么就成了文件?」后来写了个简单的 echo 服务器才明白:你创建一个 Socket,得到一个 fd(文件描述符),然后对这个 fd 做 read 和 write,数据就在网络上传输了。跟读写本地文件几乎一样。

从编程角度看,Socket 就是一组 API 的集合:

  • socket() —— 创建一部「电话机」
  • bind() —— 给电话机绑定一个号码(IP + 端口)
  • listen() —— 把电话机调到待机状态
  • accept() —— 接听来电
  • connect() —— 拨号出去
  • send() / recv() —— 通话,说和听
  • close() —— 挂断电话

嗯,这套流程我闭着眼都能背出来。因为我在生产环境里写过不下几十次。

Socket 的两种主要类型

Socket 分两种,对应传输层的两大协议:TCP 和 UDP。它们的区别,说白了就是一个靠谱一个快。

特性 流式 Socket (SOCK_STREAM) 数据报 Socket (SOCK_DGRAM)
底层协议 TCP UDP
连接方式 面向连接,先建立连接再通信 无连接,直接发数据
数据可靠性 保证到达,顺序一致 不保证到达,可能乱序
数据边界 无边界,流式数据 有边界,每个包独立
典型场景 HTTP、FTP、邮件 DNS、视频直播、游戏

流式 Socket(SOCK_STREAM)

这种 Socket 基于 TCP,特点是可靠、有序、面向连接。你发出去的数据,TCP 保证对方能收到,而且顺序不乱。

但有个坑:它是流式的,没有消息边界。什么意思呢?你调用两次 send() 各发了 100 字节,对方可能一次 recv() 就收回了 200 字节。也可能分三次收:50、100、50。边界在哪?你自己得想办法。

我曾经踩过的坑:写第一个 HTTP 解析器时,我天真地以为一次 recv() 就能收完整个 HTTP 请求头。结果在高并发下频繁出现「半包」问题——请求头只收到一半,解析直接崩了。后来才加上消息边界检测和缓冲区拼接逻辑。

数据报 Socket(SOCK_DGRAM)

这种基于 UDP,特点是快速、无连接、不可靠。每次 sendto() 就是一个独立的数据报,对方 recvfrom() 收到的就是完整的一包。

好处是简单——不用维护连接状态,发就完了。坏处是——丢了就丢了,没人管你。

我个人的习惯是:如果丢一两个包不影响业务,就用 UDP。比如视频通话、游戏位置同步。但如果涉及交易、支付、文件传输,老老实实用 TCP。

Socket 通信模型:C/S 架构

几乎所有 Socket 程序都遵循 Client-Server(客户端-服务器) 模型。这个架构其实很直观:

  • Server:一直等着,谁来接谁
  • Client:主动发起连接,然后通信

下面这张图是我自己画的标准 C/S 通信流程,你看一眼就明白了:

Socket C/S 通信流程 Socket C/S 通信流程 客户端 (Client) 服务端 (Server) socket() 创建套接字 connect() 发起连接 send() 发送请求 recv() 接收响应 close() 关闭连接 socket() 创建套接字 bind() 绑定地址+端口 listen() 进入监听状态 accept() 接受客户端连接 recv() / send() 处理数据 💡 关键点 服务端先启动,调用 listen() 进入被动等待;客户端主动 connect() 发起三次握手。 连接建立后,双方可以双向收发数据,最后任意一方 close() 关闭连接。

这个流程里有个细节我特别想强调:服务端必须先启动,然后调用 listen() 进入被动等待状态。客户端才能 connect() 过来。如果服务端没启动,客户端 connect() 会直接返回「连接被拒绝」——这个错误我见过太多次了。

一个小技巧:调试时如果 connect() 报错,先确认服务端进程是不是活着。用 netstat -tlnp | grep 端口号 看一眼,比瞎猜快得多。

Socket 与端口的关系

端口是什么?我习惯这么理解:IP 地址是门牌号,端口是房间号。

一台服务器只有一个 IP,但可以同时跑很多服务:Web 服务、数据库、SSH……每个服务怎么区分?靠端口。HTTP 默认走 80 端口,HTTPS 走 443,MySQL 走 3306。你访问一个网站时,浏览器知道要去 80 号「房间」敲门。

从 Socket 的角度看,端口是这么工作的:

  • 服务端调用 bind() 时,要指定 IP 和端口。比如 bind(sockfd, (struct sockaddr*)&addr, sizeof(addr)),其中 addr.sin_port 就是端口号。
  • 客户端 connect() 时,也要指定目标 IP 和端口。比如连接百度:IP 是 14.215.177.39,端口是 80。
  • 操作系统通过 五元组 来唯一标识一个连接:源IP + 源端口 + 目标IP + 目标端口 + 协议。

这里有个常见的误区:端口是 16 位的,范围 0-65535。但 0-1023 是知名端口,需要 root 权限才能绑定。我当年写第一个 Web 服务器时,想用 80 端口,结果 bind() 一直报权限错误——后来才意识到要加 sudo。

我曾经犯过的错:在生产环境里,我直接把服务绑在了 1024 以下的端口上,结果忘了给进程提权。服务启动失败,线上挂了 10 分钟。从那以后,我养成了习惯:低于 1024 的端口一律用 iptables 做端口转发,应用层只用 1024 以上的端口。

一个简单的 TCP Socket 示例

光说不练假把式。我写个最简的 TCP 服务端和客户端,帮你把上面的流程串起来。

服务端代码(server.c):

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

int main() {
    int sockfd, clientfd;
    struct sockaddr_in addr;
    char buffer[1024] = {0};

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

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

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

    printf("Server listening on port 8080...\n");

    // 4. 接受连接
    clientfd = accept(sockfd, NULL, NULL);

    // 5. 接收数据
    recv(clientfd, buffer, sizeof(buffer), 0);
    printf("Received: %s\n", buffer);

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

客户端代码(client.c):

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

int main() {
    int sockfd;
    struct sockaddr_in addr;
    char *message = "Hello, Socket!";

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

    // 2. 设置目标地址
    addr.sin_family = AF_INET;
    addr.sin_port = htons(8080);
    inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);

    // 3. 连接服务端
    connect(sockfd, (struct sockaddr*)&addr, sizeof(addr));

    // 4. 发送数据
    send(sockfd, message, strlen(message), 0);
    printf("Message sent\n");

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

编译运行:

# 先启动服务端
gcc server.c -o server && ./server

# 另开终端,运行客户端
gcc client.c -o client && ./client

你会看到服务端打印出 "Received: Hello, Socket!"。这就是最基础的 Socket 通信。

小结一下

这一章我们聊了 Socket 的本质——它就是操作系统给应用层的一个网络读写接口。两种类型:流式 Socket 靠谱但没边界,数据报 Socket 快但不靠谱。通信模型就是经典的 C/S 架构,服务端等着,客户端主动连。端口则是用来区分同一台机器上不同服务的「房间号」。

这些概念看着简单,但我在实际项目中见过太多人栽在细节上。比如 bind() 端口被占用、connect() 没加超时、recv() 没处理半包……后面几章我们会一个一个拆解这些问题。