socket是应用层与TCP/IP协议族通信的中间软件抽象层。它是一组接口,把复杂的TCP/IP协议族隐藏在socket接口中。

socket工作流程

TCP:

UDP:

socket

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

domain:套接字使用的网络协议栈和地址类型

  • AF_INET:用于 IPv4 互联网协议。
  • AF_INET6:用于 IPv6 互联网协议。
  • AF_UNIXAF_LOCAL:用于在同一台机器上的进程间通信(IPC),使用文件路径作为地址。

type:套接字类型

  • SOCK_STREAM:流式套接字,基于 TCP 协议。
  • SOCK_DGRAM:数据报套接字,基于 UDP 协议。

protocol:具体的协议。如果domaintype已经足够明确,则将其设置为0,内核会自动选择。

  • IPPROTO_TCP
  • IPPROTO_UDP

每个socket被创建后,无论使用的是TCP协议还是UDP协议,都会创建自己的接收缓冲区发送缓冲区。这两个缓冲区位于内核内存中,用于暂存待发送或已接收的网络数据。

  • socket缓冲区在每个套接字中单独存在
  • socket缓冲区在创建套接字时自动生成
  • 即使关闭套接字也会继续传送发送缓冲区中遗留的数据
  • 关闭套接字将丢失接收缓冲区中的数据

bind

bind()函数负责将socket 与本地地址关联起来,内核会验证提供的本地地址(IP 地址和端口号)是否有效且未被占用。如果验证通过,内核就会将这个地址信息与套接字文件描述符绑定。

/*
 * sockfd: socket文件描述符
 * addr: 要绑定的本地地址信息:IP地址和端口号,一般由sockaddr_in强制转换
 * addrlen: addr结构体的大小,通常是sizeof(sockaddr_in)
 */
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

listen

listen()函数将一个已绑定的套接字设置为监听模式,使其能够接收和处理来自客户端的连接请求。

/*
 * sockfd: socket文件描述符
 * n: 全连接队列的最大长度
 */
int listen(int sockfd, int n);

调用listen()函数时,内核会为这个套接字分配并初始化两个队列:

  • 半连接队列:存放已收到SYN包但尚未完成TCP三次握手的连接请求。
  • 全连接队列:存放已经完成TCP三次握手,但尚未被服务器accept()函数取走的连接。

并将套接字设置为监听模式:

  • 内核会修改套接字的状态,使其从CLOSED或状态变为LISTEN状态。
  • 从现在开始,该套接字将开始监听指定端口上的连接请求。

accept

accept()函数是一个阻塞式系统调用。它负责从全连接队列中取出一个已经完成 TCP三次握手的连接,并为这个连接创建一个新的独立的套接字,供服务器与该客户端进行通信。如果队列为空,accept()函数会阻塞等待。

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

sockfd:处于监听模式的套接字。

addrsockaddr结构体指针。在accept()调用返回时,会被填充客户端的地址信息(IP 地址和端口号)。使服务器能够知道是哪个客户端发起了连接。

addrlen:指socklen_t指针,调用accept()前,必须被设置为*addr所指向的结构体的大小。accept()返回后,会被修改为实际填充到*addr结构体中的地址信息的大小。

返回值:返回新创建的套接字文件描述符。


调用accept()函数时:

  1. 检查全连接队列,如果队列为空,accept()函数会阻塞,直到有新的连接进入队列。如果全连接队列不为空,内核会从队列头部取出一个已完成三次握手的客户端连接。
  2. 使用客户端的IP地址和端口填充到addr中,并为这个连接创建一个全新的、独立的套接字。

socket()accept()创建两个不同的socket,承担不同的职责:

  1. 监听 Socket:由socket()创建的、经过bind()listen()设置的socket,它的职责就是监听端口。
  2. 连接 Socket:由accept()为每一个成功的连接创建的新的 Socket,它的职责是与特定的客户端进行数据传输

connect

connect()函数由客户端发起调用, 用于向指定的服务器发起连接请求connect()会负责完成三次握手

/*
 * sockfd: socket文件描述符
 * addr: 服务器的地址信息,通常被强制类型转换成sockaddr_in或sockaddr_in6
 * addrlen: addr结构体的大小
 */
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

调用connect()函数时,

  1. 如果客户端的套接字没有使用bind()函数绑定一个本地地址,内核会自动为它分配一个临时端口号和本地 IP 地址。
  2. 内核使用addr中指定的服务器 IP 地址和端口号,向服务器发送一个 SYN数据包。
  3. 此时connect()函数会阻塞,等待服务器的响应。
  4. 客户端收到 SYN-ACK后,再发送ACK数据包给服务器。
  5. 发送完第ACK包后,进入等待确认,如果计时器超时之前没有再收到SYN-ACK包,connect就会成功返回。

send

/*
 * sockfd: connect()(客户端)或 accept()(服务器端)建立的连接套接字
 * buf: 要发送的数据的缓冲区的指针
 * len: 要发送的数据的长度/字节
 * flags: 控制发送行为的标志位。通常为0
 */
ssize_t send(int sockfd, const void *buf, size_t len, int flags);

send()函数只负责将数据提交给协议层。 当调用该函数时,send()先比较待发送数据的长度和套接字的发送缓冲区的长度:

  1. 如果待拷贝数据的长度大于发送缓冲区的长度时,该函数返回SOCKET_ERROR;
  2. 如果拷贝数据的长度小于或等于发送缓冲区的长度时,那么send先检查协议是否正在发送缓冲区中的数据:
    1. 如果是,就阻塞等待协议把数据发送完,再进行拷贝;
    2. 如果协议还没有开始发送缓冲区中的数据或者该发送缓冲区中没有数据,那么send就比较该发送缓冲区中的剩余空间和待拷贝数据的长度:
      1. 如果待拷贝数据的长度大于剩余空间的大小,send就阻塞等待协议把该发送缓冲区中的数据发完;
      2. 如果待拷贝数据的长度小于剩余空间大小,send就仅仅把buf中的数据拷贝到剩余空间中。 (注意:并不是send把该套接字的发送缓冲区中数据传到连接的另一端,而是协议传的,send仅仅是把数据拷贝到该发送缓冲区的剩余空间里面。)

send()的阻塞行为取决于发送缓冲区是否有足够的空间以及套接字是否被设置为非阻塞模式,如果套接字被设置为非阻塞,如果发送缓冲区已满,send()会立即返回SOCKET_ERROR,并设置errnoEAGAINEWOULDBLOCK

send函数返回值:

  • 如果send函数拷贝成功,就返回实际拷贝的字节数;
  • 如果拷贝的过程中出现错误,send就返回SOCKET_ERROR;
  • 如果send在等待协议传送数据时网络断开的话,那么send函数也返回SOCKET_ERROR。

注意:send函数把buffer中的数据成功拷贝到套接字的发送缓冲区中的剩余空间里面后,它就返回了,但是此时这些数据并不一定马上被传到连接的另一端。

接下来,操作系统内核会在后台异步地将缓冲区中的数据打包成 TCP/IP 数据包,并发送到网络上。这个过程由内核的网络协议栈负责

recv

/*
 * sockfd: connect()(客户端)或 accept()(服务器端)建立的连接套接字
 * buf: 指向用于存放接收到的数据的缓冲区的指针
 * len: buf缓冲区的最大长度
 * flags: 控制接收行为的标志位。通常为0
 */
ssize_t recv(int sockfd, void *buf, size_t len, int flags)

recv函数仅仅是拷贝数据,真正的接收数据是协议来完成的。

recv先检查套接字的接收缓冲区,如果该接收缓冲区中没有数据或者协议正在接收数据,那么recv就阻塞等待。

直到网络数据包到达并被内核放入接收缓冲区时,recv函数就把套接字的接收缓冲区中的数据拷贝到用户层的buffer中,

recv函数返回值:

  • recv函数返回实际拷贝的字节数。如果recv在拷贝时出错(网络中断或其他异常),那么就返回-1,并设置errno变量。
  • 如果协议缓冲区内没有数据,recv返回0,指示对方对端已经正常关闭了连接;
  • 如果协议缓冲区有数据,则返回对应数据(可能需要多次recv),在最后一次recv时,返回0,指示对方关闭。

注意:协议接收到的数据可能大于buffer的长度,所以在这种情况下,要调用几次recv函数才能把套接字接收缓冲区中的数据拷贝完。

sendto

sendtosend类似,但是:

  • sendto()返回成功只表示数据进入了缓冲区,并不关心数据是否被接收。
  • send()成功返回表示数据进入了缓冲区,并且 TCP协议会保证它最终被对方接收。
/*
 * sockfd: connect()(客户端)或 accept()(服务器端)建立的连接套接字
 * buf: 要发送的数据的缓冲区的指针
 * len: 要发送的数据的长度/字节
 * flags: 控制发送行为的标志位。通常为0
 * dest_addr: 接收方的地址信息(IP地址和端口号)
 * addrlen: dest_addr 结构体的大小
 */
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
               const struct sockaddr *dest_addr, socklen_t addrlen);

recvfrom

与recv类似。

/*
 * sockfd: connect()(客户端)或 accept()(服务器端)建立的连接套接字
 * buf: 指向用于存放接收到的数据的缓冲区的指针
 * len: buf缓冲区的最大长度
 * flags: 控制接收行为的标志位。通常为0
 * src_addr: recvfrom返回时,内核会填充发送方的地址信息(IP地址和端口号)
 * addrlen: recvfrom返回时,会被内核修改为实际填充到*src_addr结构体中的地址信息的大小
 */
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                 struct sockaddr *src_addr, socklen_t *addrlen);

sendmsg

sendmsg()能够在一个系统调用中发送多个不连续的数据缓冲区,并且可以发送辅助数据

/*
 * sockfd: connect()(客户端)或 accept()(服务器端)建立的连接套接字
 * msg: 封装了所有发送所需的信息
 * flags: 控制发送行为的标志位。通常为0
 */
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);

msghdr结构体:

struct msghdr {
    void         *msg_name;       /* 目标地址,用于UDP */
    socklen_t     msg_namelen;    /* 地址长度 */
    struct iovec *msg_iov;        /* 零散数据缓冲区数组 */
    size_t        msg_iovlen;     /* 零散缓冲区数量 */
    void         *msg_control;    /* 辅助数据 */
    size_t        msg_controllen; /* 辅助数据长度 */
    int           msg_flags;      /* 消息标志,通常为0 */
};

sendmsg()支持分散-聚集 I/O (Scatter-Gather I/O)msghdr结构体中的msg_iovmsg_iovlen字段允许指定一个包含多个缓冲区的数组。

内核会遍历这个数组,将所有缓冲区中的数据按顺序拷贝到socket的发送缓冲区。这避免了在用户空间中将多个小数据块复制到一个大缓冲区,再进行一次系统调用的开销。

  • 如果是UDP套接字,从msg_namemsg_namelen获取目标地址信息,并将数据发送到该地址。

+ 如果是TCP套接字,msg_name则被忽略。

recvmsg

/*
 * sockfd: connect()(客户端)或 accept()(服务器端)建立的连接套接字
 * msg: 封装了所有接收所需的信息
 * flags: 控制接收行为的标志位。通常为0
 */
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);

使用场景:数据包通常由一个固定长度的头部和可变长度的正文组成,可以直接将iovec数组设置为:iovec[0]指向Header结构体。iovec[1]指向Body缓冲区。

sendmmsg

sendmmsg()sendmsg()的批量版本,允许通过一次系统调用发送多个独立的数据报。

sendmmsg()的核心思想是减少系统调用的开销

如果你要发送N个数据报,就需要调用 N 次sendto()sendmsg()。每次系统调用都会涉及到用户态和内核态之间的切换,这个切换本身就消耗 CPU 资源。

sendmmsg()通过一次性将多个数据报的发送请求提交给内核,大大减少了这种开销。

/*
 * sockfd: connect()(客户端)或 accept()(服务器端)建立的连接套接字
 * msgvec: mmsghdr结构体数组, 每个mmsghdr结构体都代表一个要发送的数据报。
 * vlen: msgvec 数组中元素数量
 * flags: 控制发送行为的标志位。通常为0
 */
int sendmmsg(int sockfd, struct mmsghdr *msgvec, unsigned int vlen, int flags);

mmsghdr结构体

struct mmsghdr {
    struct msghdr msg_hdr;      /* 封装了数据和地址的msghdr结构体 */
    unsigned int msg_len;       /* 实际发送的字节数 */
};

sendmsg()虽然可以发送来自多个缓冲区的数据,但它在内核中只会聚集成一个数据报,然后发送。

sendmmsg()的设计目的就是为了发送多个独立的数据报

recvmmsg

/*
 * sockfd: connect()(客户端)或 accept()(服务器端)建立的连接套接字
 * msgvec: mmsghdr结构体数组, 每个mmsghdr结构体都代表一个要接收的数据报。
 * vlen: msgvec数组中元素数量
 * flags: 控制发送行为的标志位。通常为0
 * timeout: 指定recvmmsg()的等待超时时间。如果设置为NULL,则会一直阻塞直到有数据到来。
 */
int recvmmsg(int sockfd, struct mmsghdr *msgvec, unsigned int vlen,
             int flags, struct timespec *timeout);

recvmmsg()的核心思想也是为了减少系统调用的开销

sockopt

int setsockopt(int sockfd, int level, int optname,
               const void *optval, socklen_t optlen);

int getsockopt(int sockfd, int level, int optname,
               void *optval, socklen_t *optlen);

sockfd:套接字文件描述符。

level:选项所在的协议层

  • SOL_SOCKET:用于设置套接字层面的通用选项。
  • IPPROTO_TCP:用于设置 TCP 协议层面的选项。
  • IPPROTO_IP:用于设置 IP 协议层面的选项。

optname:具体选项名称。

SOL_SOCKET 作用
SO_REUSEADDR 允许重用处于TIME_WAIT状态的本地地址和端口。无需等待TIME_WAIT状态超时。
SO_REUSEPORT 允许多个完全独立的套接字绑定到同一个地址和端口。
SO_KEEPALIVE 启用 TCP 心跳包机制。当连接长时间没有数据交互时,内核会发送探测包来检测对端是否存活,防止僵尸连接。
SO_SNDBUF 设置发送缓冲区的大小(以字节为单位)
SO_RCVBUF 设置接收缓冲区的大小(以字节为单位)
SO_SNDTIMEO 设置发送超时。如果send()在指定时间内无法完成,将返回错误。
SO_RCVTIMEO 设置接收超时。如果recv()在指定时间内没有接收到数据,将返回错误。
SO_LINGER 控制close()函数的行为。可以设置在关闭套接字时是立即返回还是等待发送缓冲区中的数据发送完毕。
SO_SNDLOWAT 发送缓冲区低水位标记。当缓冲区中的可用空间达到或超过该值时,select/poll会返回可写事件。
SO_RCVLOWAT 接收缓冲区低水位标记。当缓冲区中的数据量达到或超过该值时,select/poll会返回可读事件。
IPPROTO_TCP 作用
TCP_NODELAY 禁用 Nagle 算法。Nagle 算法会延迟发送小数据包以将其合并,从而减少网络开销。禁用它可以降低发送延迟
TCP_MAXSEG 设置 TCP 最大分段大小(MSS)。
TCP_CORK 启用 TCP “软木塞”机制。它会阻止发送部分数据帧,直到被显式关闭或发送缓冲区已满,类似于手动控制 Nagle 算法。
TCP_QUICKACK 启用或禁用快速确认。通常在处理大量小数据包时使用,可以减少 ACK 延迟。
TCP_DEFER_ACCEPT 允许服务器在接收到数据后再完成accept。这可以防止服务器处理半连接的恶意请求。

optval:要设置的选项值的指针。这个值的类型取决于optname

optlenoptval所指向的数据的长度。

close

关闭套接字,close()会向对端发送一个 FIN 包,开始四次挥手过程,以终止连接并释放资源。

  • 发送缓冲区中数据都会被丢弃,除非设置SO_LINGER选项。

  • 接收缓冲区数据都会被丢弃

int close(int fd);

shutdown

优雅地关闭连接。shutdown()允许分阶段地关闭连接的读端写端,而不会立即释放所有 Socket 资源。

int shutdown(int sockfd, int how);

how:一个整数,指定了要关闭连接的哪个部分。

  • SHUT_RD (0):关闭连接的读端。无法再从该 Socket 接收数据。任何仍在接收缓冲区中的数据都将被丢弃,并且任何后续的recv()调用都会立即返回0(表示对端关闭)。
  • SHUT_WR (1):关闭连接的写端。无法再向该 Socket 发送数据。任何仍在发送缓冲区中的数据都会被尝试发送出去,之后系统会向对端发送一个 FIN 包。后续的send()调用会失败。
  • SHUT_RDWR (2):同时关闭连接的读端和写端。这相当于同时调用shutdown(SHUT_RD)shutdown(SHUT_WR)