Socket基础:从零开始理解网络通信

各位同学,今天我们来聊聊Socket。说实话,Socket这个概念刚接触时确实有点抽象。我当年学的时候,脑子里全是问号——这玩意儿到底是个啥?

简单来说,Socket就是网络通信的"门"。你想跟另一台机器上的程序说话,就得先开一扇门。这扇门就是Socket。它屏蔽了底层网络协议的复杂性,让你能像读写文件一样进行网络通信。

Socket的概念与原理

Socket起源于Unix,设计哲学就是"一切皆文件"。网络通信本质上也是读写数据,只不过数据是在网络上传输,而不是在本地磁盘上。

我习惯把Socket理解成一个通信端点。两端各有一个Socket,通过IP地址和端口号来定位对方。就像你寄快递,IP地址是小区地址,端口号是具体门牌号。

核心要点:Socket是应用层与传输层之间的抽象接口。它封装了TCP/IP协议栈的细节,让开发者只需关注数据的收发。

通信流程其实很直观:服务端先创建Socket,绑定地址,然后监听。客户端创建Socket,连接服务端。连接建立后,双方就可以收发数据了。最后关闭连接。

Socket通信流程 socket() 创建 bind() 绑定 listen() 监听 accept() 接受 close() 关闭 socket() 创建 connect() 连接 close() 关闭 TCP三次握手 send() / recv() 数据收发

Socket的分类

Socket有三种类型,每种对应不同的传输方式。我当年面试时就被问过这个,答不上来可尴尬了。

类型 名称 协议 特点
SOCK_STREAM 流式Socket TCP 面向连接、可靠、有序、无边界
SOCK_DGRAM 数据报Socket UDP 无连接、不可靠、有边界、效率高
SOCK_RAW 原始Socket IP/ICMP等 直接操作网络层,可自定义协议

流式Socket(SOCK_STREAM):基于TCP,保证数据按顺序到达,不丢失。就像打电话——先建立连接,再通话,最后挂断。我在项目中做文件传输时,用的就是这种。

数据报Socket(SOCK_DGRAM):基于UDP,不保证可靠,但速度快。就像寄明信片——扔进邮筒就不管了,对方收不收得到看运气。做视频直播时,丢几帧画面无所谓,用UDP就对了。

原始Socket(SOCK_RAW):这个比较底层,可以直接构造IP包。我曾在做网络监控工具时用过,能抓到最原始的网络数据包。

我的建议:初学者先掌握流式Socket,这是最常用的。数据报Socket也建议学,很多高性能场景离不开它。原始Socket嘛,等你把前两个玩熟了再说。

Socket的地址结构:sockaddr_in

通信需要地址。在IPv4网络中,我们用sockaddr_in结构体来存放地址信息。这个结构体长这样:

struct sockaddr_in {
    sa_family_t    sin_family;  // 地址族,AF_INET
    in_port_t      sin_port;    // 端口号,网络字节序
    struct in_addr sin_addr;    // IP地址
    char           sin_zero[8]; // 填充,全0
};

嗯,这里有几个坑要注意。我刚开始写代码时,经常忘记把端口号转成网络字节序。用htons()函数转换一下就行。IP地址用inet_pton()把点分十进制转成二进制。

我曾经踩过的坑:sin_zero字段一定要清零。否则bind()可能失败,返回"Address already in use"。用memset(&addr, 0, sizeof(addr))就能搞定。

Socket的创建:socket()

创建Socket很简单,一个函数调用:

#include <sys/socket.h>

int sockfd = socket(int domain, int type, int protocol);

参数说明:

  • domain:协议域,IPv4用AF_INET,IPv6用AF_INET6
  • type:Socket类型,SOCK_STREAMSOCK_DGRAM
  • protocol:协议,通常传0,让系统自动选择

举个例子,创建一个TCP Socket:

int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
    perror("socket creation failed");
    exit(EXIT_FAILURE);
}

返回的文件描述符就是Socket的句柄。之后所有操作都通过它来完成。

Socket的绑定:bind()

创建完Socket,服务端需要把它绑定到一个具体的地址和端口上。这样客户端才知道往哪连。

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

我习惯这样写:

struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
server_addr.sin_addr.s_addr = INADDR_ANY;  // 监听所有网络接口

if (bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
    perror("bind failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}

注意:客户端不需要调用bind(),系统会自动分配一个临时端口。只有服务端才需要显式绑定。

Socket的监听:listen()

绑定之后,服务端要告诉内核:我准备好了,可以接受连接了。这就是listen()做的事。

int listen(int sockfd, int backlog);

backlog参数指定连接队列的最大长度。什么意思呢?就是同时有多少个连接请求可以排队等待处理。

我一般设成5或10。设太大没意义,反而浪费资源。设太小的话,高并发时客户端会连不上。

if (listen(sockfd, 5) < 0) {
    perror("listen failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}

Socket的连接:connect() 和 accept()

这是通信双方建立连接的关键步骤。客户端调用connect(),服务端调用accept()。

客户端:connect()

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

客户端需要指定服务端的IP和端口:

struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
inet_pton(AF_INET, "192.168.1.100", &server_addr.sin_addr);

if (connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
    perror("connect failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}

服务端:accept()

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

accept()会阻塞,直到有客户端连接进来。它返回一个新的Socket描述符,用于和这个客户端通信。原来的监听Socket继续等待其他连接。

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 < 0) {
    perror("accept failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}

我的经验:accept()返回的client_fd才是真正用来收发数据的。原来的sockfd只负责监听。很多新手搞混这个,结果数据发不出去。

Socket的关闭:close() 和 shutdown()

通信结束,记得关闭Socket。这不仅是好习惯,更是必须的——否则会资源泄漏。

close():关闭Socket,释放文件描述符。调用后不能再读写。

close(sockfd);

shutdown():更精细的控制。可以只关闭读、只关闭写,或者都关闭。

int shutdown(int sockfd, int how);
// how: SHUT_RD(关闭读), SHUT_WR(关闭写), SHUT_RDWR(都关闭)

为什么要用shutdown?举个例子:你发完数据想告诉对方"我说完了",但还想接收对方的回应。这时用shutdown(sockfd, SHUT_WR),发送FIN包,但保留读通道。

我曾经踩过的坑:close()只是把文件描述符的引用计数减1。如果Socket被多个进程共享,要等所有进程都close()才会真正关闭。shutdown()则立即生效,不管引用计数。

好了,Socket的基础知识就讲到这里。这些是网络编程的基石,一定要亲手敲代码试试。光看是学不会的,我当年也是写了无数遍才真正理解。


公众号:蓝海资料掘金营,微信deep3321