socket编程

socket

Socket(套接字)是计算机网络编程中用于实现网络通信的一种机制,它提供了一种编程接口,允许应用程序通过网络进行数据传输,实现不同主机之间的通信。Socket 本质是对 TCP/IP 协议的封装,是 TCP/IP 提供给程序员进行网络开发的接口 。

可以将 Socket 理解为不同计算机上应用程序之间进行双向通信的端点

就好比电话通信中,电话是通信工具,而每个电话接口就是一个 Socket,两台计算机通过各自的 Socket 建立连接来传输数据。

Socket 有多种类型,常见的如: 流套接字(SOCK_STREAM):也叫 TCP 套接字,提供可靠的、面向连接的、双向的字节流通信服务。数据按顺序发送和接收,不会丢失或重复,适用于对数据完整性要求高的场景,像文件传输、网页浏览等。 数据报套接字(SOCK_DGRAM):即 UDP 套接字,提供无连接的、不可靠的、基于消息的数据报服务。每个数据报独立传输,可能会出现顺序错乱、重复或丢失的情况,但传输速度快,适合对实时性要求高、能容忍少量数据丢失的场景,比如视频直播、在线游戏等。

文件描述符

文件描述符(File Descriptor)是在 Linux操作系统中内核用于标识和管理进程所打开文件或其他 I/O 资源(如套接字、管道等)的非负整数 。其本质是一个索引值,指向内核为每个进程维护的打开文件记录表。

在进程打开现有文件或创建新文件时,内核会返回一个文件描述符。一般的,头三个数字被内核占用,标准输入(stdin)的文件描述符是 0,标准输出(stdout)是 1,标准错误(stderr)是 2 ,其他打开的文件或资源的文件描述符从 3 开始依次递增。文件描述符的有效范围一般是 0 到OPEN_MAX ,不同系统对此上限的规定有所不同。

除了以前所熟知的用法,比如进程使用open函数打开磁盘上的普通文件时,内核会返回一个普通文件描述符,以供进程对文件进行读写操作外。在网络编程中,还可以使用socket函数创建套接字文件描述符(简称套接字描述符),提供给进程来进行网络通信,如发送和接受数据。

文件描述符在socket 编程中起到以下作用:

  • 网络通信标识:在 socket 编程里,通过socket函数创建套接字后会返回一个文件描述符,它是该套接字在进程文件描述符表中的索引。凭借这个文件描述符,进程能够对相应套接字进行读写、设置选项等操作 ,实现与其他主机的网络通信。例如使用read函数从套接字文件描述符读取接收到的数据,用write函数向其写入要发送的数据。

  • 兼容 I/O 操作:基于文件描述符的 I/O 操作符合 POSIX 标准 。在 UNIX、Linux 的众多系统调用中,很多依赖文件描述符。这使得 socket 编程能与其他基于文件描述符的 I/O 操作统一起来,比如可以像操作普通文件描述符那样,使用selectpollepoll等 I/O 多路复用机制来管理多个套接字文件描述符,高效处理网络事件通知,实现同时监听多个网络连接。

  • 资源管理依据:文件描述符代表着系统资源,每个进程能打开的文件描述符数量有限。在 socket 编程时,需合理管理套接字文件描述符,及时用close函数关闭不再使用的套接字,释放资源,防止因过度占用文件描述符导致系统资源耗尽,保障程序稳定运行。

字节序

网络环境中通过ip地址可以唯一标识一台主机;如果还要标识主机上的进程,可以加上port。

所以要想在网络环境中识别一个进程,需要使用 ip + port,而传输ip和端口号的过程中需要注意字节序的转换。

Tip

大端:低地址存的是高位,高地址存的是低位。

小端:低地址存的是低位,高地址存的是高位。

网络字节序,就是在网络中进行传输的字节序列,采用的是大端法。

主机字节序,就是本地计算机中存储数据采用的字节序列,大端和小端都有。

下面的四个函数,它们是用于实现主机字节序(host)和网络字节序(net)之间转换的函数。

h代表本机,n代表网络,ip用long型数据表示,port用short数据表示。

地址结构体

IPv4和IPv6的地址格式定义在netinet/in.h中,IPv4地址用sockaddr_in结构体表示,IPv6地址使用sockaddr_in6结构体表示。

UNIX Domain Socket的地址格式定义在sys/un.h中,使用sockaddr_un结构体表示。所有的地址类型分别定义为常数AF_INETAF_INET6AF_UNIX

地址结构体

sockaddr

  • 通用地址表示:是一种通用的套接字地址结构体,用于表示各种类型的套接字地址,能兼容不同协议族(如 IPv4 、IPv6、UNIX 域套接字等 )。通过指定sa_family字段来区分地址类型,sa_data字段存储实际的地址数据,其格式和长度随地址族不同而变化。

  • 接口适配:在一些 socket 相关函数(如bindconnectrecvfromsendto等 )中,需要使用该结构体来指明地址信息。不过,由于它缺乏针对特定协议的便利性,实际编程中一般不直接使用,而是使用其变体(如sockaddr_insockaddr_in6 ),这些变体可强制转换为sockaddr 类型进行函数调用。


sockaddr_in

  • IPv4 地址与端口表示:专门用于表示 IPv4 地址和端口的结构体,是sockaddr 针对 IPv4 地址的专用形式 。在头文件<netinet/in.h> 中定义。

  • 数据成员

    • sin_family:地址族字段,固定为AF_INET,表示使用 IPv4 地址 。

    • sin_port:用于存储端口号,需使用htons()函数将主机字节序转换为网络字节序(大端序 ) 。

    • sin_addr:类型为in_addr结构体,用来保存 IPv4 地址 。

  • 网络通信应用:在 socket 编程中,使用sockaddr_in 来方便地管理 IPv4 地址和端口号,进行套接字的本地或远程地址指定,实现网络通信基础操作,如服务器绑定地址和监听连接等。


in_addr

  • IPv4 地址存储:用于表示一个 32 位的 IPv4 地址的结构体 ,其中in_addr_t(一般为 32 位的unsigned int )类型的s_addr成员变量,以网络字节序(大端序 )存储实际的 IPv4 地址 。

  • 地址转换协助:常配合inet_ptoninet_ntop等函数使用,实现 IPv4 地址在字符串形式和结构体形式之间的转换,方便 IP 地址的存储、处理及传输 。


sockaddr_storage

有些函数,比如accept(),可以接收远端的地址。使用struct sockaddr_storage来接收远端地址,既可以接收 IPv4 的地址,也可以接收 IPv6 的地址(甚至还可以接收本地套接字地址)。

IP地址转换

inet_pton()inet_ntop(),这两个函数可以将 IP 地址在文本形式和二进制形式之间进行转换(p for presentation or printable if you like, n for network)。


inet_pton()

参数

af: 地址族。AF_INET表示 IPv4 地址,AF_INET6表示 IPv6 地址。

src: 文本形式的 IP 地址。比如“10.12.110.57”,或“2001:db8:63b3:1::3490”。

dst: 二进制形式的 IP 地址要写入的地址,并且是以网络字节序写入的。

以前做这种转换的函数是inet_addr()inet_aton()。不过它们现在已经过时了,并且它们不支持 IPv6。


inet_ntop()可以将 IP 地址从二进制形式转换成文本形式。

参数

af: 地址族。AF_INET表示 IPv4 地址,AF_INET6表示 IPv6 地址。

src: 指向二进制的 IP 地址。

dst: 指向一个字符数组,用来存储文本形式的 IP 地址。

size: 字符数组的长度。

以前做这种转换的函数是inet_ntoa()。不过现在它已经过时了,并且它不支持 IPv6 。

getaddrinfo

getaddrinfo()函数会动态申请一些内存空间,并构建一条链表。该链表的结点是一个addrinfo结构体, addrinfo结构体里面包含一个套接字地址。该套接字地址能够匹配nodeservice,并且满足hints里面设定的限制条件。

参数

node: 网络上的结点(主机)。可以是域名,如 “www.baidu.com”;也可以是具体的 IP 地址,如 "10.12.110.57"。

service: 网络服务(端口)。可以是服务名,如 “http”;也可以是端口号,如 “9527”。

hints: addrinfo结构体。可以在hints里面设置一些返回结果必须满足的限制条件。

res: 指向链表的第一个结点。


示例:

服务端:


客户端:

socket进行TCP通信

socket

调用socket函数创建一个socket通信端点

domain:协议域,指定使用的协议族,如AF_INET(IPv4)、AF_INET6(IPV6)

type:指定 socket 类型,常见的有SOCK_STREAM(TCP)、SOCK_DGRAM(UDP)

protocol:指定协议,如IPPROTO_TCP (TCP)、IPPTOTO_UDP (UDP),一般将protocol设为0,系统会自动选择type类型对应的默认协议。

函数返回值:成功就返回一个非负整数,指向新创建的socket的文件描述符,失败则返回-1

bind

将套接字与特定的 IP 地址和端口号绑定,使得该套接字可以在指定的地址和端口上接收连接或发送数据。

bind函数将sockfdaddr绑定在一起,使sockfd这个用于网络通信的文件描述符监听addr所描述的地址和端口号。

sockfdsocket函数创建的套接字描述符

addr:指向要绑定的ip地址与端口号的sockaddr结构体的指针

addrlen:前一个参数addr结构体的长度

函数返回值:成功就返回0,失败返回-1,并设置 errno 指示错误,常见错误如 EADDRINUSE 表示地址已被使用。

Note

客户端也可以bind端口号与IP地址,如果没有显式绑定的话,操作系统会自动分配一个IP地址与端口号。但是服务器是不能不使用bind函数,让操作系统随机分配IP地址与端口号,因为这样的话客户端就不知道服务器的IP地址与端口号,就不知道怎么连接到服务器上了,也不知道连接到哪个服务器上。

listen

将一个套接字设置为监听状态,准备接受客户端的连接请求。

sockfdsocket函数创建的套接字描述符

backlog:指定允许的最大全连接队列长度。

函数返回值:成功返回0,失败返回-1,并设置 errno 指示错误,例如 EINVAL 表示套接字不是一个有效的监听套接字。

一旦启用了listen之后,操作系统就知道该套接字是服务端的套接字,操作系统内核就不再启用其发送和接收缓冲区(回收空间),转而在内核区维护两个队列结构: 半连接队列和全连接队列。

accept

使用accept函数从服务端的socket通信端点的全连接队列中取出一个连接,并创建一个新的套接字用于与客户端进行通信。

sockfd:监听状态的套接字描述符

addr:指向用于存储客户端地址信息(包含IP地址与端口号)的 sockaddr结构体指针

addrlen:指向 addr结构体长度的指针

函数返回值:成功返回一个新的socket文件描述符,用于和客户端通信,失败返回-1,并设置 errno 指示错误,如 ECONNABORTED 表示连接被中断。

Tip

后两位参数如果传入空指针,意味着服务端程序不关心发起连接的客户端的地址信息。

当服务端只关注数据传输本身,不在意连接来源时,可以不用获取客户端的地址信息了。如果是服务端需要分析客户端的网络位置去排查问题,或进行某些访问控制时,则必须获取到客户端的地址信息。

connect

客户端调用该函数,尝试与服务器建立连接

sockfd:是客户端自己调用socket函数创建的套接字描述符

addr:指向服务器地址信息(包含IP地址与端口号)的 sockaddr 结构体指针

addrlen:传入sizeof(addr)大小

函数返回值:成功返回0,表示连接建立成功,失败返回-1,并设置 errno 指示错误,例如 ECONNREFUSED 表示服务器拒绝连接。

send

在已连接的socket通信端点上发生数据

sockfd:要发送数据的套接字描述符

buf:指向要发送数据的缓冲区指针

len:要发送数据的字节数

flags:控制发送行为的标志,通常设为 0。

函数返回值:成功时返回实际发送的字节数,可能小于 len。失败时返回 -1,并设置 errno指示错误,如EPIPE表示连接已断开。

recv

从已连接的socket通信端点上接收数据

sockfd:要接收数据的套接字描述符

buf:指向用于存储接收数据的缓冲区指针

len:缓冲区的最大长度

flags:控制接收行为的标志,通常设为 0

函数返回值:成功时返回实际接收的字节数;如果对端关闭连接,返回 0;失败时返回 -1,并设置 errno 指示错误,如ECONNRESET 表示连接被重置。

close

关闭socket通信端点,释放相关资源,终止网络连接

fd:要关闭的套接字描述符

函数返回值:成功时返回 0;失败时返回 -1,并设置 errno 指示错误。如 EBADF表示文件描述符参数无效。

示例

服务器:socketbindlistenacceptclose

客户端:socketconnectclose

客户端和服务器各自执行socket方法,得到代表连接的文件描述符。然后服务器依次执行bindlistenaccept方法,然后等待客户端执行connect方法(发起建立连接的请求)。连接建立后,客户端可以执行send方法发送消息,服务器执行recv接受消息。或者服务器执行send发送消息,客户端执行recv接收消息。

writeread 函数不仅适用于 socket 编程,还可用于普通文件等其他文件描述符相关的 I/O 操作;

sendrecv 主要用于 socket 编程,更侧重于网络数据的收发,与网络协议结合更紧密。相较于writeread 函数,还多了一个标志位参数,能够更灵活地控制读写操作。


服务端代码


客户端

Tip

地址复用与端口复用

地址复用允许通信端点绑定到同一个 IP + 端口 ,即使仍处于 TIME_WAIT 状态(即前一个连接刚刚关闭,但还未完全释放)。可以迅速进行重复使用,不至于等待2MSL的时间。

目前实现的效果是,一个服务器只能跟一个客户端进行连接。如果想要用一个服务器进程同时监听多个客户端,就要用到IO多路复用。

IO多路复用

核心概念

IO多路复用(I/O Multiplexing)是一种高效的网络编程技术,允许单个线程同时监控多个文件描述符(socket),当其中任何一个准备好进行IO操作时,系统会通知应用程序。

传统IO模型的问题:

IO多路复用的优势:

原理:

  • 应用程序通过系统调用(如 select、poll、epoll 等)向内核注册需要监听的文件描述符以及感兴趣的事件(如读事件、写事件)。

  • 内核会不断地检查这些文件描述符的状态,当有事件发生时,内核会通知应用程序。

  • 应用程序接收到通知后,就可以对发生事件的文件描述符进行相应的 IO 操作。

select

原理与函数接口

select的基本原理, 就是把要监视的文件描述符, 构建一个文件描述符监听集合; 这个集合交给select, select促使操作系统内核, 通过轮询的方式监听这个文件描述符集合。直到监听集合中, 至少有一个文件按照条件就绪(条件:预设的监听是读就绪OR写就绪), 这一次的select监听宣告结束, 并携带就绪的文件描述符集合返回, 继续执行用户的代码逻辑。

虽然应用进程需要遍历文件描述符集合来确定哪些就绪。但这个 “遍历” 是在 内核通知就绪之后 进行的,属于 被动检查,而非应用进程主动轮询硬件设备。

select函数参数

第二参数实际是指向位图的指针,对某个文件描述符进行监听,只需要将相应的位设为1就可以了。

readfs/writes/exceptfds:监控有读数据/写数据/异常发生到达文件描述符集合,三个都是传入传出参数。

平时大多数情况下没有监听写事件或者异常事件,所以第三、四个参数一般直接传空指针。

3种情况: 1、NULL,永远等下去,一直阻塞直到所监听的文件描述符集合中有一个或多个文件描述符发生相应事件 2、传入非零的struct timeval结构体指针,等待固定时间 3、传入struct timeval结构体指针,但设置timeval里时间均为0,select函数不会阻塞,而是立即返回,一般放在循环中实现轮询。

通过这个监听集合,可以实现对多个socket的同时监听。

当监听集合完成一次遍历,发现有套接字处于就绪状态,也就是某些套接字的缓冲区中有数据需要处理时,就会返回一个就绪码(处于就绪状态的套接字数量),并通过传入传出参数传出一个就绪集合(就绪的套接字的位码置1,未就绪的套接字的位码置0)

select函数返回值 成功:所监听的所有的监听集合中,满足条件的总数 —— 就绪的文件描述符个数。 失败:返回-1。

Tip

位图可以通过下标访问,为什么不直接进行随机访问呢?

select 使用的位图(fd_set)虽然可以通过下标访问,但它本身没有记录哪些文件描述符处于就绪状态的额外信息。当 select 函数返回后,操作系统只是修改了位图中对应就绪文件描述符的位为 1,但没有直接标记出具体哪些位发生了变化,也就是不知道就绪的下标。所以只能通过遍历位图,用 FD_ISSET 宏函数逐个检查文件描述符对应的位是否为 1 ,才能确定哪些文件描述符就绪。

相关的宏

Tip

INADDR_ANY 是一个特殊的 IPv4 地址表示,值为 0.0.0.0 ,代表不确定地址

泛指本机的意思,表示本机的所有IP,因为有些电脑不止一块网卡,如果某个应用程序只监听某个端口,那么其他端口过来的数据就接收不了。

示例代码

poll

原理与函数接口

Poll允许程序同时监控多个文件描述符,当其中任何一个准备好进行IO操作时,poll会返回并告知哪些文件描述符已经就绪。


系统调用流程

  1. 准备阶段: 应用程序创建pollfd数组,设置要监控的文件描述符和事件类型

  2. 系统调用: 调用poll()函数,将控制权交给内核

  3. 内核处理: 内核检查所有指定的文件描述符状态

  4. 阻塞等待: 如果没有事件就绪,进程进入睡眠状态

  5. 事件唤醒: 当有事件发生时,内核唤醒进程

  6. 返回结果: poll()返回,revents字段被设置为实际发生的事件


内核实现机制

文件描述符管理:

事件检测:

唤醒机制:

相比Select的优势

  1. 没有文件描述符数量限制

    • Select: 受FD_SETSIZE限制,通常为1024

    • Poll: 只受系统内存限制,可以监控更多文件描述符

  1. 更高效的数据结构

    • Select: 使用位图(fd_set),需要遍历整个位图

    • Poll: 使用结构体数组,只处理实际的文件描述符

  1. 更清晰的事件处理

    • Select: 需要重新设置fd_set,事件类型有限

    • Poll: events和revents分离,支持更多事件类型

  1. 更好的可移植性

    • Select: 在不同系统上行为可能不一致

    • Poll: POSIX标准,跨平台一致性更好

fds:文件描述符数组。

events:POLLIN/POLLOUT/POLLERR 对应读事件、写事件、异常事件

nfds:监控数组中有多少文件描述符需要被监控。

timeout 毫秒级等待

-1:阻塞等,#define INFTIM -1 Linux中没有定义此宏

0:立即返回,不阻塞进程

>0:等待指定毫秒数,如当前系统时间精度不够毫秒,向上取值。

函数返回值:满足监听条件的文件描述符的数目。

示例代码

epoll

原理与函数接口

epoll是Linux下IO多路复用接口select/poll的增强版本,能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率,因为它会复用文件描述符集合来传递结果而不是迫使开发者每次等待事件之前都必须重新准备要侦听的文件描述符集合,另一个原因是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历哪些被内核IO事件唤醒而加入就绪集合的描述符集合就行了。

epoll_create 函数

创建一个 epoll 实例,返回一个文件描述符,后续的 epoll_ctl 和 epoll_wait 函数将使用这个文件描述符来操作和等待事件。

参数: size:在早期的 epoll 实现中,该参数用于告知内核要监听的文件描述符的大致数量,帮助内核提前分配所需的内存。但从 Linux 2.6.8 开始,这个参数被忽略了,不过为了兼容性,仍需传入一个大于 0 的值。

返回值: 成功时,返回一个非负的 epoll 实例的文件描述符。失败时,返回 -1,并设置 errno 来指示错误类型。

epoll_ctl 函数 对 epoll 实例所监听的文件描述符进行控制操作,包括注册、修改和删除监听的事件。

参数:

在内核中,每个 fd对应的驱动对象会注册一个 就绪回调函数

回调函数的作用:当 fd 状态变化(如 socket 接收缓冲区有数据),驱动会触发该回调函数,通知 epoll 内核模块。

返回值: 成功时,返回 0。失败时,返回 -1,并设置 errno 来指示错误类型。

epoll_wait 函数 等待 epoll 实例中所监听的文件描述符上有事件发生。当有事件发生时,将这些事件信息复制到 events 数组中。

参数

返回值 成功时,返回发生事件的文件描述符的数量;超时返回时,返回 0;失败时,返回 -1,并设置 errno 来指示错误类型。

用户通过 epoll_ctl 注册 FD 时:

  • 内核将 FD 添加到红黑树中,并为其关联一个 事件结构体(epitem),记录关注的事件类型。

  • 在内核中,每个 FD 对应的驱动对象会注册一个 就绪回调函数

  • 回调函数的作用:当 FD 状态变化(如 socket 接收缓冲区有数据),驱动会触发该回调函数,通知 epoll 内核模块。

当用户调用 epoll_wait 时,内核进入阻塞状态:

  • 对于每个注册的 FD,其对应的内核驱动(如网络设备驱动)会在 数据就绪时自动触发回调函数

  • 回调函数执行逻辑

    (1)检查该 FD 的事件是否满足注册时的条件(如可读事件是否发生)。

    (2)若满足,将该 FD 及其事件类型添加到 就绪列表 中,并唤醒 epoll_wait 调用。

核心特点

  • 内核无需主动遍历红黑树,而是通过驱动的回调函数被动接收就绪事件,并将其存入就绪列表。

  • epoll_wait 的时间复杂度仅与就绪事件的数量 (M) 相关((O(M))),而非总 FD 数 (N)

示例代码

LT与ET

水平触发

水平触发(Level Triggered, LT)是 epoll 的默认工作模式,其行为模式与传统的 selectpoll 类似。

工作原理与特性:

优点:

缺点:

使用场景:

边缘触发

边缘触发 (Edge Triggered, ET)是一种更高效但也更复杂的模式。

工作原理与特性:

优点:

缺点:

使用场景: