3、Socket编程基础(上):Socket概念、socket()、bind()、listen()、accept()函数详解、TCP三次握手与四次挥手
各位同学,咱们今天正式进入Socket编程的世界。说实话,我当年刚接触网络编程时,觉得Socket这玩意儿特别神秘——一个文件描述符,怎么就能跟千里之外的机器通信了呢?后来才明白,它其实就是操作系统给我们开的一扇门,门后面是完整的网络协议栈。
这一节,咱们把Socket编程中最核心的几个函数掰开揉碎了讲。你想想看,一个TCP服务器从启动到能接收客户端连接,本质上就五步:创建Socket、绑定地址、监听、接受连接、收发数据。今天咱们先讲前四步。
核心要点:Socket是网络通信的端点,是应用层与传输层之间的抽象接口。在Linux中,它就是一个文件描述符。
3.1 Socket概念:它到底是什么?
Socket这个词,直译是“插座”。我觉得这个比喻挺贴切的——你家里的电器要用电,得把插头插到插座上。网络通信也一样,两个进程要通信,各自得有一个Socket,然后通过这个“插座”把数据送出去。
从程序员的角度看,Socket就是一套API。这套API屏蔽了底层协议细节(TCP、UDP、IP),让你能像读写文件一样进行网络通信。嗯,这里要注意:Socket不仅仅用于TCP,UDP、Unix域套接字也都是通过它来操作的。
我个人习惯把Socket分为三类:
- 流式套接字(SOCK_STREAM):面向连接,可靠,对应TCP
- 数据报套接字(SOCK_DGRAM):无连接,不可靠,对应UDP
- 原始套接字(SOCK_RAW):直接操作IP层,一般用于开发网络工具
咱们这门课主要讲流式套接字,也就是TCP。为什么?因为大部分高性能服务器(Web服务器、游戏服务器、消息队列)用的都是TCP。
3.2 socket()函数:创建通信端点
一切从socket()开始。这个函数的作用很简单——向内核申请一个Socket资源,返回一个文件描述符。
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
// 示例:创建一个TCP Socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1) {
perror("socket");
exit(EXIT_FAILURE);
}
参数解释:
- domain:协议域,
AF_INET表示IPv4,AF_INET6表示IPv6 - type:套接字类型,
SOCK_STREAM表示流式 - protocol:具体协议,传0表示让系统自动选择(TCP对应IPPROTO_TCP)
避坑指南:我曾经在项目中看到有人把第三个参数传IPPROTO_TCP,其实传0就够了。除非你要用原始套接字,否则别给自己找麻烦。
这里有个细节:socket()返回的文件描述符,默认是主动的(active),也就是用于发起连接。如果你想让它变成被动的(passive),后面需要调用listen()。
3.3 bind()函数:绑定地址和端口
创建了Socket之后,它就像一张白纸,没有绑定任何地址信息。你需要用bind()给它贴上标签——告诉内核:“这个Socket归这个IP和端口管”。
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
// 示例:绑定到0.0.0.0:8080
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网卡
server_addr.sin_port = htons(8080);
if (bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) {
perror("bind");
close(sockfd);
exit(EXIT_FAILURE);
}
这里有几个容易踩的坑:
- 端口占用:如果端口已经被占用,
bind()会返回EADDRINUSE。我遇到过好几次,程序崩溃重启时端口还没释放,这时候可以用setsockopt()设置SO_REUSEADDR。 - 字节序:网络字节序是大端,主机字节序可能是小端。所以要用
htons()和htonl()转换。忘了这个,端口号会变成别的值。 - 地址结构体:
struct sockaddr_in要清零,否则里面的垃圾数据可能导致绑定失败。
注意:客户端通常不需要调用bind(),系统会自动分配一个临时端口。只有服务器才需要显式绑定知名端口。
3.4 listen()函数:进入监听状态
bind()之后,Socket还处于“待机”状态。你需要调用listen()告诉内核:“嘿,我准备好接受连接了,把进来的连接请求排个队吧。”
#include <sys/socket.h>
int listen(int sockfd, int backlog);
// 示例:开始监听,最大等待队列长度为128
if (listen(sockfd, 128) == -1) {
perror("listen");
close(sockfd);
exit(EXIT_FAILURE);
}
backlog这个参数很有意思。它指定了已完成三次握手但还未被accept()取走的连接队列的最大长度。说白了,就是“最多允许多少个客户端在门口等着”。
我记得在Linux 2.2之前,backlog指的是半连接队列(SYN_RCVD状态)的大小。后来改成了全连接队列(ESTABLISHED状态)的大小。这个细节曾经坑过不少人——你设了很大的backlog,但客户端还是连不上,可能就是半连接队列满了。
经验之谈:在高并发场景下,backlog不要设得太大,128到512通常就够了。太大了反而会导致连接积压,客户端超时重试,造成雪崩效应。
3.5 accept()函数:接受客户端连接
终于到了accept()。这个函数从全连接队列里取出一个已经完成三次握手的连接,返回一个新的文件描述符。注意,是新的文件描述符!原来的监听Socket还在继续监听。
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
// 示例:接受连接,并打印客户端地址
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int client_fd = accept(sockfd, (struct sockaddr*)&client_addr, &client_len);
if (client_fd == -1) {
perror("accept");
close(sockfd);
exit(EXIT_FAILURE);
}
printf("新连接来自: %s:%d\n",
inet_ntoa(client_addr.sin_addr),
ntohs(client_addr.sin_port));
这里有几个关键点:
- 阻塞行为:默认情况下,如果全连接队列为空,
accept()会阻塞,直到有新连接到来。 - 客户端信息:第二个参数和第三个参数用于获取客户端的IP和端口。如果你不关心,可以传NULL。
- 文件描述符:返回的
client_fd用于后续的read()/write()通信。用完记得close(),否则会泄漏文件描述符。
避坑指南:我曾经在写一个多线程服务器时,忘了把client_fd设置成非阻塞。结果某个客户端不按套路出牌,一直不发数据,导致整个线程卡在read()上。后来我养成了习惯:accept()之后立刻设置O_NONBLOCK。
3.6 TCP三次握手与四次挥手
讲完了函数,咱们得聊聊底层原理。TCP是面向连接的协议,建立连接需要三次握手,断开连接需要四次挥手。你想想看,为什么是三次而不是两次?为什么是四次而不是三次?
三次握手:建立连接
三次握手的本质是:双方都要确认对方的收发能力正常。
- 客户端 → 服务器:SYN(seq=x)
客户端说:“我想跟你建立连接,这是我的序列号x。” - 服务器 → 客户端:SYN+ACK(seq=y, ack=x+1)
服务器说:“收到你的请求,这是我的序列号y,我确认你的x。” - 客户端 → 服务器:ACK(seq=x+1, ack=y+1)
客户端说:“我确认你的y,咱们可以开始通信了。”
为什么是三次?说白了,如果只有两次,服务器无法确认客户端是否收到了自己的SYN+ACK。万一丢了,服务器会一直傻等。三次握手让双方都确认了对方的接收能力。
实战关联:你在调用connect()时,内核会帮你完成三次握手。而accept()返回时,三次握手已经完成了。所以,accept()之前,连接其实已经建立好了。
四次挥手:断开连接
TCP是全双工的,所以断开连接时,每一方都要单独关闭自己的发送通道。
- 主动方 → 被动方:FIN(seq=u)
主动方说:“我不再发数据了。” - 被动方 → 主动方:ACK(seq=v, ack=u+1)
被动方说:“收到你的FIN,但我可能还有数据要发。” - 被动方 → 主动方:FIN(seq=w, ack=u+1)
被动方说:“我的数据发完了,我也要关了。” - 主动方 → 被动方:ACK(seq=u+1, ack=w+1)
主动方说:“收到你的FIN,连接关闭。”
这里有个经典问题:为什么主动方在发送ACK之后还要等待2MSL(最大报文段生存时间)?
嗯,原因很简单——怕最后一个ACK丢了。如果被动方没收到ACK,会重发FIN。主动方如果在2MSL内收到重发的FIN,可以再发一次ACK。过了2MSL还没收到,说明被动方已经收到了ACK,可以安心关闭了。
注意:我在项目中遇到过close()之后端口一直处于TIME_WAIT状态的情况。如果服务器频繁创建和关闭连接,TIME_WAIT会堆积,导致端口耗尽。解决方案是用SO_REUSEADDR或者改用长连接。
3.7 知识体系总览
下面这张图把本章的核心逻辑串起来了。从Socket创建到连接建立,再到连接关闭,每一步都有对应的系统调用和TCP状态。
3.8 本章小结
这一节咱们把Socket编程的五个核心函数过了一遍,顺便复习了TCP的三次握手和四次挥手。说白了,服务器编程就是围绕这几个函数展开的:
socket():创建通信端点bind():绑定地址和端口listen():进入监听状态accept():接受客户端连接- 再加上后面的
read()/write()和close()
这些函数看似简单,但每个都有不少细节。我建议你亲手写一个echo服务器试试,把每个函数的返回值都检查一遍,看看出错时是什么表现。实践出真知,光看代码是记不住的。