Socket 编程入门:从概念到实战

说实话,很多初学者一上来就啃 Socket 源码,结果越看越懵。我当年也是这样——看了三天文档,连一个简单的回显服务器都写不出来。后来我才明白,问题出在没搞懂 Socket 到底是什么。

Socket,说白了就是操作系统提供的一个编程接口。它把复杂的网络协议栈包装成了几个简单的函数调用。你不需要关心数据怎么分包、怎么路由、怎么重传,只需要调用 send() 和 recv() 就行了。

一句话总结:Socket 是应用层与传输层之间的桥梁。你写网络程序,本质上就是在操作这个桥梁。

Socket 的类型

Socket 分两种,对应传输层的两大协议:TCP 和 UDP。我习惯叫它们「流式」和「数据报」。

类型 名称 底层协议 特点
SOCK_STREAM 流式 Socket TCP 面向连接、可靠、有序、无边界
SOCK_DGRAM 数据报 Socket UDP 无连接、不可靠、无序、有边界

流式 Socket 就像打电话。你先拨号(连接),对方接听(确认),然后你们可以一直聊(双向通信),挂断后连接就断了。数据像水流一样,源源不断,但你也分不清哪句话是哪次说的——这就是「无边界」的含义。

数据报 Socket 就像寄明信片。你写好地址扔进邮筒,对方能不能收到、什么时候收到、会不会丢,你都不确定。但每张明信片都是独立的,收到的人一眼就能看出这是单独的一封信——这就是「有边界」。

我的经验:做文件传输、网页服务这类需要可靠性的场景,用流式 Socket。做视频直播、游戏同步这类能容忍丢包但要求低延迟的场景,用数据报 Socket。我在做一款实时对战游戏时,就用了数据报 Socket 来同步玩家位置——偶尔丢一帧无所谓,但延迟高了游戏就没法玩了。

Socket 的工作流程

流式 Socket 和数据报 Socket 的工作流程差别很大。咱们先看流式的,因为它更常用,也更容易理解。

流式 Socket(TCP)流程

  1. 服务端:创建 Socket → 绑定地址 → 监听 → 接受连接 → 收发数据 → 关闭
  2. 客户端:创建 Socket → 连接服务端 → 收发数据 → 关闭

你看,服务端比客户端多了「绑定」「监听」「接受」三步。为什么?因为服务端需要固定一个地址,让客户端能找到它。客户端不需要——它主动连过去就行了。

// 服务端伪代码
socket()          // 创建 Socket
bind()            // 绑定 IP 和端口
listen()          // 开始监听
accept()          // 等待客户端连接(阻塞)
recv() / send()   // 收发数据
close()           // 关闭连接

// 客户端伪代码
socket()          // 创建 Socket
connect()         // 连接服务端
recv() / send()   // 收发数据
close()           // 关闭连接

我曾经踩过的坑:刚学 Socket 时,我写了个服务端,忘了调用 bind() 就直接 listen() 了。结果系统随机分配了一个端口,客户端根本连不上。查了半天才发现——bind() 不是可选的,是必须的。

数据报 Socket(UDP)流程

数据报 Socket 就简单多了。没有连接,没有监听,直接发。

  1. 服务端:创建 Socket → 绑定地址 → 收发数据 → 关闭
  2. 客户端:创建 Socket → 收发数据 → 关闭
// 服务端伪代码
socket()          // 创建 Socket
bind()            // 绑定 IP 和端口
recvfrom()        // 接收数据(同时获取发送方地址)
sendto()          // 发送数据(需要指定目标地址)
close()           // 关闭

// 客户端伪代码
socket()          // 创建 Socket
sendto()          // 发送数据(指定服务端地址)
recvfrom()        // 接收数据
close()           // 关闭

你发现没有?UDP 的客户端甚至不需要 connect()。它想发给谁就发给谁,每次 sendto() 时指定目标地址就行了。这就是「无连接」的真正含义。

地址结构

Socket 编程里,地址结构是个绕不开的东西。它用来描述一个网络端点——说白了,就是「谁在哪儿」。IPv4 和 IPv6 的地址结构不一样,但 Linux 用了一个巧妙的设计把它们统一起来了。

结构体 用途 关键字段
struct sockaddr 通用地址结构 sa_family(地址族)
struct sockaddr_in IPv4 地址结构 sin_family、sin_port、sin_addr
struct sockaddr_in6 IPv6 地址结构 sin6_family、sin6_port、sin6_addr

你写代码时,用的是 sockaddr_in,但传给 bind()、connect() 这些函数时,需要强转成 sockaddr。为什么?因为函数接口设计时不知道你会用 IPv4 还是 IPv6,所以统一用通用结构体做参数。你传进去,函数根据 sa_family 字段来判断实际类型。

// IPv4 地址结构示例
struct sockaddr_in addr;
addr.sin_family = AF_INET;          // IPv4 地址族
addr.sin_port = htons(8080);        // 端口号(网络字节序)
addr.sin_addr.s_addr = INADDR_ANY;  // 绑定所有网卡

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

注意:htons() 和 htonl() 这两个函数,用来把主机字节序转成网络字节序。为什么需要这个?因为不同 CPU 的字节序不一样——有的高位在前,有的低位在前。网络协议统一规定用大端序,所以你得转一下。我刚开始写代码时经常忘了调 htons(),结果端口号对不上,连都连不上。

Socket 工作流程全景图

下面这张图,把流式 Socket 和数据报 Socket 的完整流程放在一起对比。你看一眼,心里就有数了。

Socket 工作流程对比 流式 Socket(TCP) 服务端:socket() 服务端:bind() 服务端:listen() 服务端:accept()(阻塞) 客户端:socket() 客户端:connect() 三次握手 send() / recv() 数据报 Socket(UDP) 服务端:socket() 服务端:bind() 客户端:socket() sendto() / recvfrom() UDP 无连接,无需 listen/accept 每次 sendto() 指定目标地址 数据可能丢失、乱序

这张图里,左侧是 TCP 的完整流程,右侧是 UDP 的。你对比一下就能看出来——TCP 多了 listen() 和 accept(),而且客户端必须 connect()。UDP 呢?直接发就完了。

核心区别:TCP 是面向连接的,通信前要先建立连接。UDP 是无连接的,想发就发,不管对方在不在。这个区别决定了它们各自适用的场景。

小结

这一章我们聊了 Socket 的基本概念、两种类型、工作流程和地址结构。你记住几个关键点:

  • Socket 是操作系统提供的网络编程接口,屏蔽了底层协议细节
  • 流式 Socket(TCP)可靠但有连接开销,数据报 Socket(UDP)轻量但不可靠
  • TCP 流程:socket → bind → listen → accept → recv/send → close
  • UDP 流程:socket → bind → recvfrom/sendto → close
  • 地址结构用 sockaddr_in,传给系统函数时要强转成 sockaddr
  • 别忘了用 htons() 转字节序——我吃过这个亏