[学习笔记] - Socket套接字

套接字

一个TCP连接套接字对是一个定义该连接的两个端点的四元组:本地IP,本地TCP端口,外地IP,外地TCP端口。套接字对唯一标志一个网络上的每个TCP连接。

套接字:(IP地址, 端口号),就是IP地址+端口

IPv4套接字

网际套接字地址结构,以sockaddr_in命名,在<netinet/in.h>头文件种。以下这个结构体就可以看作是一个套接字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct in_addr {
// 32-bit IPv4 address
in_addr_t s_addr;
}

struct sockaddr_in {
uint8_t sin_len;
// AF_INET
sa_family_t sin_family;
in_port_t sin_port;

// 32-bit IPv4 address
struct in_addr sin_addr;
// unused
char sin_zero[8];
}

What does a type followed by _t (underscore-t) represent?
What does “sin” mean in sin_addr, sin_family etc,?

希望上面2个连接可以帮助解决为什么这socket的变量名为什么这么奇葩的疑惑。

_t可以看作是用来定义一种数据类型。来防止因为操作系统或架构的原因,导致原生的数据类型有不同

对于POSIX规范来说,只需要实现下面三个字段就行了

  • sin_family
    • sa_family_t:可以是任何无符号整数类型,通常是8位
  • sin_addr
    • in_addr_t:IPv4地址,一般为uint32_t
  • sin_port
    • in_port_t:需要是16位无符号整数类型,来表示端口

套接字地址在作为参数传进任何套接字函数的时候,通常以引用(指针)的方式。在<sys/socket.h>头文件种,定义了一个通用的套接字地址结构,因为上面的sockaddr_in也只是POSIX的标准。需要一个通用标准。

1
2
3
4
5
struct sockaddr {
uint8_t sa_len;
sa_family_t sa_family;
char sa_data[14];
}

这样才不必在不同函数中,发现套接字结构体不一样,于是我们的bind函数变为

1
int bind(int, struct sockaddr *, socklen_t)

所以,在声明了sockaddr_in后,往往需要进行强制转换为sockaddr

1
bind(sockfd, (struct sockaddr *) &serv, sizeof(serv));

我们传递参数还需要传递参数的长度,sizeof是无法从指针获取到结构体的大小的,只能获取到指针的大小。所以这里结构的长度也作为一个参数来传递。当指针和指针所指的内容的大小都传递给了内核,内核才知道要从用户进程复制多少数据到内核空间进来。这也是为什么那么多socket函数要传长度的原因

从内核到进程传递套接字结构的函数有4个,所以它们都有套接字的指针和大小这2个参数

  • accept
  • recvfrom
  • getsockname
  • getpeername

至于为什么有时要把数值大小也作为指针传递进去,既然我们用户线程要告诉内核需要复制多少,在返回结果的时候,内核也要告诉我们用户线程返回的结果多大,我们才知道复制多少,而不至于越界。

套接字地址结构在进程和内核之间反复传递,使用套接字都是内核中的系统调用

大小端模式

2种方式并没有什么特别不一样,网络协议需要指定一个网络字节序。规定使用大端字节序来传递

1
2
3
4
5
6
#include <netinet/in.h>

uint16_t htons(uint16_t host16bitvalue)
uint32_t htonl(uint32_t host32bitvalue)
uint16_t ntohs(uint16_t net16bitvalue)
uint32_t ntohl(uint32_t net32bitvalue)
  • h:代表host
  • n:代表network
  • s:代表short
  • l:代表long

字节操作函数

1
2
3
4
5
6
7
8
#include <strings.h>

// 置0
void bzero(void *dest, size_t nbytes);
// 复制
void bcopy(const void *src, void *dest, size_t nbytes);
// 比较
int bcmp(const void *ptrl, const void *ptr2, size_t nbytes)

地址转换函数

转换ASCII字符串到网络字节序的二进制

1
2
3
4
5
6
7
8
9
10
11
12
#include <arpa/inte.h>

// 将C字符串转换成一个32位的网络字节序二进制值
int inet_aton(const char *strptr, struct in_addr *addrptr);
// 有问题,被废弃
in_addr_t inet_addr(const char *strptr);
// 将32位的网络字节序二进制值转换成一为C字符串
char *inet_ntoa(struct in_addr inaddr);

// IPv4, IPv6都可用
int inet_pton(int family, const chat *strptr, void *addrptr);
const char *inet_ntop(int family, const void *addrptr, char *strptr, size_t len);

IO操作

  • 一次read操作所返回的数据可能少于所要求的数据
  • 一次write操作的返回值也可能少于指定输出的字节数

readn和writen的功能是读、写指定的N字节数据,并处理返回值小于要求值的情况
这两个函数只是按需多次调用read和write直至读、写了N字节数据

1
2
3
4
#include "unp.h"
ssize_t readn(int filedes, void *buff, size_t nbytes);
ssize_t writen(int filedes, void *buff, size_t nbytes);
ssize_t readline(int filedes, void *buff, size_t maxlen);

socket函数

用于创建套接字

1
2
#include <sys/socket.h>
int socket(int family, int type, int protocol);
  • family:指明协议族
  • type:指明套接字类型
  • protocol某个协议类型常值

返回套接字描述符

connect函数

TCP客户端用于与服务端建立连接

1
2
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen);
  • sockfdsocket函数返回的套接字描述符
  • servaddr:指向套接字地址结构体的指针
  • addrlen:套接字地址结构体的大小

connect不必调用bind,因为内核会确定源IP和一个随机临时端口

非阻塞connect

在一个非阻塞的TCP套接字上调用connect时,connect将立刻返回一个EINPROGRESS错误,不过已经发起的TCP三次握手继续进行。我们可以继续使用select检测这个连接或成功或失败的已建立条件

  • 我们可以把三次握手的时间用在其他事情上面,一次connect需要一个RTT时间
  • 我们可以使用这个计数同时建立多个连接
  • 使用select指定超时,缩短超时时间

问题

  • 如果服务器在同一主机,连接一般立刻建立
  • 当连接成功建立,描述符变得可写
  • 当连接建立错误,描述符变得可读可写

非阻塞connect能使我们在TCP三次握手发送期间做其他处理

bind函数

把本地一个协议地址赋予一个套接字

1
2
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen);
  • sockfdsocket函数返回的套接字描述符
  • servaddr:指向套接字地址结构体的指针
    • 因为const,若让内核指定端口,需要调用getsockername来返回协议地址
  • addrlen:套接字地址结构体的大小

服务端在启动时绑定一个端口,所以服务端需要使用bind,绑定设计三个对象

  • 套接字
  • 地址
  • 端口

通配地址:INADDR_ANYhtonl(INADDR_ANY)

listen函数

做两件事

  • socket创建的套接字为一个主动套接字,listen将其转换为一个被动套接字,用于接受指向该套接字的连接请求
  • 规定了内核应该认为相应套接字排队的最大连接个数
1
2
#include <sys/socket.h>
int listen(int sockfd, int backlog);

内核为任何一个监听套接字维护2个队列,见[学习笔记 - TCP/IP](/2021/01/11/tcp-ip-note/#半连接队列和全连接队列)

  • 未完成连接队列
  • 已完成连接队列

当进程调用accept时,已完成队列种的队首将返回给进程,如果队列为空,进程将被投入睡眠(阻塞)

accept函数

用于服务端从已完成连接队列的对手返回下一个已完成连接,如果为空,则阻塞

1
2
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *ciladdr, socklen_t *addrlen);
  • sockfd:监听套接字
  • ciladdr:用于返回对端进程的协议地址
  • addrlen:返回对端进程的协议地址的长度

如果accept成功,则返回值是由内核自动生成的一个全新的描述符,代表与所返回客户的TCP连接。称为已连接套接字,服务器一般仅仅创建一个监听套接字,他在该服务器生命周期内一致存在。内核为每个由服务器进程接受的客户连接创建一个已连接套接字。完成服务时,对应的已连接套接字就被关闭。

非阻塞accept

1
2
3
4
5
6
7
for (...) {
if (FD_ISSET(listenfd, &rset)) {
sleep(5);
clilen = sizeof(cliaddr);
connfd = Accept(listenfd, (SA *) &cliaddr, &clilen);
}
}

如果客户端在accept之前发送RST,服务端会阻塞在accept上,除非有新连接到达,而无法处理本次其他已经就绪的描述符

  • 当使用select获悉某个监听套接字上何时有已完成连接准备好被accept时,把监听套接字设置为非阻塞
  • 在后续的accapt调用中忽略以下错误:EWOULDBLOCKECONNABORTEDEPROTOEINTR

close函数

用来关闭套接字,终止TCP连接

1
2
#include <unistd.h>
int close(int sockfd);

getsockname函数和getpeername函数

返回与某个套接字关联的本地协议地址,或者返回与某个套接字关联的外地协议地址

1
2
3
#include <sys/socket.h>
int getsockname(int sockfd, struct sockaddr *localaddr, socklen_t *addrlen);
int getpeername(int sockfd, struct sockaddr *peeraddr, socklen_t *addrlen);

shutdown函数

我们需要一种关闭TCP连接其中一半的方法,我们想给服务器发送一个FIN,告诉它我们已经完成了数据发送,但是仍然保持套接字以便读物,由shutdown()函数来完成。

  • close()函数:
    • close()把描述符的引用计数减1,在计数变为0时才关闭套接字
    • close()终止读和写两个方向的数据传送
1
2
#include <sys/socket.h>
int shutdown(int sockfd, int howto);
  • howto
    • SHUT_RD:关闭连接的读这一半
      • 套接字中不再有数据可接受
      • 接受缓冲区中现有的数据都会被丢弃
    • SHUT_WR:关闭连接的写这一半
      • 成为半关闭
      • 发送缓冲区中现有的数据都会被丢弃
    • SHUT_RDWR:都关闭
      • 与调用shutdown二次等效

shutdown()用于形成半关闭状态

recv函数和send函数

通过参数控制读写数据

1
2
3
#include <sys/socket.h>
ssize_t recv(int sockfd, void *buff, size_t nbytes, int flag);
ssize_t send(int sockfd, const void *buff, size_t nbytes, int flag);
  • sockfd:套接字描述符
  • buff:发送或接受缓冲区
  • nbytes:缓冲区长度
  • flag:发送或接受数据的控制参数
    • 0:相当于readwrite
    • MSG_DONTROUTE:发送数据不查找路由表,适用于局域网,或同一网段
    • NSG_DONTWAIT:仅本操作非阻塞
    • MSG_OOB:发送和接受带外数据
    • MSG_PEEK:接受数据时不从缓冲区移除
    • MSG_WAITALL:数据量不够时,读操作等待,不返回

套接字描述符是用来标定系统为当前的进程划分的一块缓冲空间的,类似于文件描述符,不过二者有些区别

  • 这块缓冲空间并不是一开始就被系统划分给进程的
    • 对于server端而言,划分系统缓冲空间的时刻是:当server决定接收来自client的连接请求
      • accept方法成功执行
    • 对于client端而言,划分系统缓冲空间的时刻是:当client端执行connect函数正确的时候

已经发送到网络的数据依然需要暂存在send buffer中,只有收到对方的ack后,kernel才从buffer中清除这一部分数据。接收端将收到的数据暂存在receive buffer中,自动进行确认。但如果socket所在的进程不及时将数据从receive buffer中取出,最终导致receive buffer填满,由于TCP的滑动窗口和拥塞控制,接收端会阻止发送端向其发送数据。

并发服务器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
pid_t pid;
int listenfd, connfd;

Bind(listenfd, ...);
Liten(listenfd, LISTENQ);

for (;;) {
connfd = Accept(listenfd, ...);
if ((pid = Fork()) == 0) {
Close(listenfd);
handle(connfd);
Close(connfd);
exit(0);
}
}

I/O复用

Blocking NON-Blocking
Synchronous Read/Write Read/Write
Asynchronous I/O Multiplexing AIO

进程需要一种预先告知内核的能力,使得内核一旦发现进程指定的一个或多个I/O条件就绪,就通知进程,这个能力称为I/O复用

进程切换

进程切换会发生一下操作

  1. 保存处理机上下文,包括程序计数器和其他寄存器。
  2. 更新PCB信息。
  3. 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
  4. 选择另一个进程执行,并更新其PCB。
  5. 更新内存管理的数据结构。
  6. 恢复处理机上下文

进程切换会消耗一定资源,切换就相当于从一个软件换用另一个软件

阻塞I/O

当客户端发起请求的时候,服务端会处理连接,并且会阻塞直到读取了数据。在阻塞期间,服务端不能做其他事情

当产生read系统调用的时候,操作系统会将Server端阻塞,并且切换到内核态。内核把数据从系统空间复制到用户空间。当缓存空了时候,内核会重新唤醒Server端进程,来进行下一次的recv

非阻塞I/O

当产生read系统调用的时候,如果内核中的数据还没有准备好,那么会立刻返回一个error。和阻塞IO不同的就是需要一直询问内核

I/O多路复用

无论是阻塞I/O还是非阻塞I/O,基本上都是一个进程处理一个连接,这样非常的浪费。而I/O多路复用,就是单个进程可以同时处理多个网络连接的I/O。

select

该函数允许进程指示内核等待多个事件中的任何一个发生,并只有在有一个或多个发生或经历一段时间后才唤醒它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <sys/select.h>
#include <sys/time.h>
int select(
int maxfdpl,
fd_set *readset,
fd_set *writeset,
fd_set *exceptest,
const struct timeval *timeout,
)

struct timeval {
long tv_sec;
long tv_usec;
}
  • timeout:告知内核等待所指定的描述符的任何一个就绪可花多长时间
    • 空指针:永远等下去
    • 非空非0:等待一段固定时间
    • 非0为0:非阻塞,轮询polling
  • readset:测试读描述符
  • writeset:写描述符
  • exceptest:异常条件描述符
  • maxfdpl:指定待测试的描述符个数

select使用描述符集,通常是一个整数数组,整数中每一位对应一个描述符。如果为32为整数,则该数组第一个元素对应描述符0~31。

1
2
3
4
5
6
7
8
// 设置全0
void FD_ZERO(fd_set *fdset);
// 设置
void FD_SET(int fd, fd_set *fdset);
// 反转
void FD_CLR(int fd, fd_set *fd_set);
// 查看
int FD_ISSET(int fd, fd_set *fd_set);

流程:

  • 创建n个fd
  • 创建一个描述符集来标志哪一个fd有数据
  • 循环
    • 将描述符集全部置为0
    • 调用select进行阻塞
    • 循环描述符集所有位,一旦为1则表示有信息,进行处理

  • 缺点
    • 采用轮询的方式扫描文件描述符:O(n)
    • 单个进程能够监视的文件描述符的数量存在最大限制,通常是1024
    • select需要将句柄数据结构在系统空间和用户空间进行复制
    • 水平触发,如果有本次没处理的fd,则下一次还是会继续出现在描述符集里
    • 描述符集不能重复使用

pselect

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <sys/select.h>
#include <sys/signal.h>
#include <sys/time.h>

int select(
int maxfdpl,
fd_set *readset,
fd_set *writeset,
fd_set *exceptest,
const struct timespec *timeout,
const sigset_t *sigmask,
)

struct timespec {
time_t tv_sec;
long tv_usec;
}
  • pselect使用timespec结构
  • pselect增加了第六个参数,指向信号掩码的指针,该参数允许程序先禁止递交某些信号,再测试由这些当前禁止信号的信号处理函数设置的全局变量,然后调用pselect,告诉它重新设置信号掩码

poll

1
2
3
4
5
6
7
8
#include <poll.h>
int poll(struct pollfd *fdarray, unsigned long nfds, int timeout);

struct pollfd {
int fd;
short events; // 用于设置监听事件
short revents; // 用于设置接收到的时间
};
  • fdarray:指向结构体数组的指针
  • nfds:结构体数组的元素个数
  • timeout:指定时时间
    • INFTIM:用于永远等待
    • 0:立刻返回
    • >0:等待指定数目的毫秒数

流程:

  • 创建n大小的pollfd结构体数组
  • 循环
    • 调用poll进行阻塞
    • 循环整个数组,判断revents是否置位,置位则表示有信息,进行处理
      • revents重新复位
      • 处理数据
    • 进行下一次循环

  • 缺点
    • 采用轮询的方式扫描文件描述符:O(n)
    • poll需要将句柄数据结构在系统空间和用户空间进行复制
    • 水平触发,如果有本次没处理的fd,则下一次还是会继续触发
  • 优点
    • 采用链表的方式储存文件描述符,去掉1024的限制
    • events可以重用

epoll

假设有100w多个连接,如果使用select()或者pull(),则每次都会将100w个结构数据复制到系统空间,经由内核处理后,再复制回来,然后还要再循环一次。无用的消耗巨大。所以select()或者pull()一般用于少于1w的并发连接。

epoll通过在内核中申请一个简易的文件系统(红黑树)。

  • 调用epoll_create()建立一个epoll对象(在epoll文件系统中为这个句柄对象分配资源)
  • 调用epoll_ctl()epoll对象中添加这100万个连接的套接字
  • 调用epoll_wait()收集发生的事件的连接
水平触发

内核中的socket接收缓冲区不为空,有数据可读,读事件一直触发
内核中的socket发送缓冲区不满,可以继续写入数据,写事件一直触发

边缘触发

内核中socket接收缓冲区由空变为不为空,数据由不可读变为可读,事件触发(仅一次)。

  • LT模式会一直触发可读可写事件,导致效率比较低。ET模式由于读写事件 仅通知一次,可能会存在数据丢失的可能。
  • ET模式时,当有多个连接同时到达服务器,epoll_wait会返回多个描述符,由于在ET模式下就绪状态只返回一次, 因此为了防止漏掉连接,需要循环调用accept直到接收全部连接(即返回EAGAIN错误)。
  • ET模式时,在读写数据时,同样需要注意读写事件只触发一次的问题,若一次读或写没有处理全部数据, 则会导致数据丢失。解决办法是,accept接收连接时,设置连接的套接字为非阻塞, 并在读写数据时循环调用read/write直到数据全部处理为止(即返回EAGAIN错误)

信号驱动I/O

让内核在描述符就绪的时候发送信号通知我们,此方法不常用

异步I/O

告知内核启动某个操作,并让内核在整个操作(包括将数据从内核复制到我们自己的缓冲区)完成后通知我们。与信号相比,信号驱动I/O是内核告诉我们合适可以启动一个I/O操作,而异步I/O模型是由内核告诉我们I/O操作完成了。

// TODO

描述符就绪条件

条件 可读 可写 异常
有数据可读 o
关闭连接的读一半 o
给监听套接口准备好新连接 o
有可用于写的空间 o
关闭连接的写一半 o
待处理错误 o o
TCP带外数据 o

套接字准备好读

  • 该套接字接收缓冲区中的数据字节数大于等于套接字接收缓冲区低水平标记的当前大小
    • 这个标记值一般是1,对这样的套接字读,将返回一个大于0的值,也就是返回准备好读入的数据
    • 默认值为:1
  • 该连接的读半部关闭(也就是收到FIN的TCP连接),对这样的套接字读,将不阻塞直接返回0(EOF)
  • 该套接字是一个监听套接字,且已完成的连接数不为0,对这样的套接字accept()通常不会阻塞。
  • 其上有一个套接字错误等待处理,对这样的套接字读,将不阻塞直接返回-1

套接字准备好写

  • 该套接字发送缓冲区中的可用空间字节数大于等于套接字发送缓冲区低水位标记的当前大小
    • 如果该套接字设置成非阻塞,写操作将不阻塞返回一个正值
    • 默认值为:2048
  • 该连接的写半部分关闭,对这样的套接字写,将产生SIGPIPE信号。
  • 使用非阻塞connect()的套接字已建立连接,或者connect()已经以失败告终
  • 其上有一个套接字错误待处理,对这样的套接字写,将不阻塞直接返回-1

套接字异常条件待处理

当某个套接字上发生错误时,它将又select标记既可读又可写。

  • 接收低水平标记和发送低水平标记的目的在于:
    • 允许应用进程控制在select返回可读或可写条件之前有多少数据可读或有多大空间可用于写。
    • 举个栗子,如果我们知道除非至少存在64个字节的数据,否则我们的应用进程没有任何有效工作,那么我们可以把接收低水平位标记为64,以防止少于64个字节的数据准备好读时select唤醒我们。

错误处理

应答超时

​在握手,挥手以及消息传递的状态下,若当前发送的报文期待一个应答报文。在规定时间应答报文没有到达,发送方会重发两次报文(报文重发间隔可设置)。若重发三次后依旧没有收到应答,则向应用返回ETIMEOUT

目的不可达

​若报文在传送的过程中,因为找不到路由路径,报文无法到达等引发了ICMP错误,发送方会按照上述的方式重发次报文。若重发结束后依旧没有收到应答,则向应用返回EHOSTUNREACH或者ENETUNREACH

禁止分片

在IPV4中,报文超过了MTU长度会导致分片,而其存在DF(Don’t Fragment)标识,表示禁止分片。同时IPV6禁止路由器分片,因此在传送的过程中隐含DF位。在传送过程中设置了DF位而超过了MTU,则会向应用返回EMSGSIZE

阻塞时中断

在系统执行慢系统调用(可能被永远阻塞的系统调用)时阻塞,此时捕获到某个信号并进行了处理(系统对某些信号有默认处理方式),在没有设置自动重启的情况下,会向应用返回EINTR

​一般应对EINTR的方式是简单的重新调用,但在connect()返回EINTR时不能这么做,因为connect()涉及三次握手的过程,需要使用getsockopt()获取连接状态。

读写时RST

在调用read()等阻塞时接收到对端RST信号时,会返回ECONNREST。同时对发送方断开的套接字写时也会返回ECONNRESET

写RST套接字

当进程向收到RST的套接字执行写操作的时候,内核向该进程发送一个SIGPIPE信号,该信号的默认行为是终止进程,因此进程必须捕获它以免不情愿的被终止

​不论进程是捕捉了该信号并从信号处理函数中返回,还是简单忽略该信号,写操作都讲返回EPIPE错误

首次握手服务端RST

​ 在客户端第一次握手时,若服务端返回RST报文,立即向应用返回ECONNREFUSED

握手结束客户端RST

在较为繁忙的服务器中,可能出现上图客户端刚经历三次握手后随机发送RST报文的情况,POSIX指出这种情况errno设置为ECONNABORTED,只需要再次调用accept()即可。

​而在Berkeley的实现中,返回EPROTO错误,代表协议错误,是一种致命错误,由内核把该连接从已完成连接套接字队列中释放,若再次调用accept(),则不会处理到本次请求,可能导致阻塞。

服务端主机崩溃

​由于客户端无法收到服务端的任何回应,会重发处理,最终向应用返回的情况可能为应答超时或目的不可达。

​若想尽快的检测出主机崩溃,不主动发送数据也可做到,即套接字选项的SO_KEEPALIVE(类似心跳机制)

服务端进程终止或关机

服务端由于进程崩溃或者手动kill后,进程终止关闭所有打开的描述符。这导致了其向客户端发送了一个FIN,客户端则响应了一个ACK,TCP挥手的前半部分完成,服务端不在发送数据。

​但是此时客户端并不知道服务器端已经终止了。当客户端向服务器写数据的时候,由于服务器进程终止,所以响应了RST

​这种情况下可以由select()或者poll()检测到服务端的终止。

  • 如果对端TCP发送数据,套接字可读,并且read()返回一个大于0的值(读入字节数)
  • 如果对端TCP发送了FIN(对端进程终止),套接字可读,并且read()返回0(EOF)
  • 如果对端TCP发送RST(对端崩溃并重启),套接字可读,并且read()返回-1,errno中含有确切错误码

UDP

  • 对于一个UDP套接字,有由它引发的异步错不返回给除非它以连接
  • 一个UDP套接字可以连接多个服务端
  • 仅在进程已将其UDP套接字连接到恰恰一个对端后,这些异步错误才返回给进程
  • UDP没有流量控制

  • UDP的通信有界性
    • 也就是client发的包只会原封不动的收下,不会分包,合并
  • UDP数据包的无序性和非可靠性

如果MTU是1500,Client发送一个8000字节大小的UDP包,那么Server端阻塞模式下接包,在不丢包的情况下,recvfrom(9000)是收到的是8000

recvfrom函数

1
2
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buff, size_t nbytes, int flags, struct sockaddr *from, socklen_t *addrlen);
  • sockfd:同read()的参数
  • buff:同read()的参数
  • nbytes:同read()的参数
  • flags:默认为0
  • from:指向一个将由该在返回时填写数据报发送者的协议地址的套接字地址结构
    • 告诉我们谁发送了数据报
  • addrlen:套接字地址结构的大小

sendto函数

1
2
#include <sys/socket.h>
ssize_t sendto(int sockfd, void *buff, size_t nbytes, int flags, struct sockaddr *to, socklen_t *addrlen);
  • sockfd:同read()的参数
  • buff:同read()的参数
  • nbytes:同read()的参数
  • flags:默认为0
  • to:指向一个将由该在返回时填写数据报发送者的协议地址的套接字地址结构
    • 告诉我们给谁发送数据报
  • addrlen:套接字地址结构的大小

UDP connect函数

不会触发三次握手,内核只是检查是否存在立即可知的错误,记录对端的IP地址和端口号,然后立即返回到调用进程

  • 未连接UDP套接字,新创建UDP套接字默认如此
  • 已连接UDP套接字,对UDP套接字调用connect()的结果
    • 不能给输出操作指定目的IP地址和端口号
    • 不使用sendto,而改用writesend
    • 不必使用recvfrom以获悉数据包的发送者,而改用readrecvrecvmsg

recvfromsendto在收发时指定地址,而sendrecv不指定,而是先用connect进行指定

  • 选定了对端,内核只会将帮定对象的对端发来的数据报传给套接口,因此在一定环境下可以提升安全性
  • 会返回异步错误,如果对端没启动,默认情况下发送的包对应的ICMP回射包不会给调用进程,如果用了connect
  • 发送两个包间不要先断开再连接,提升了效率
套接字类型 writesend 不指定目的sendto 指定目的地址的sendto
TCP套接字 o o EISCONN
UDP套接字,已连接 o o EISCONN
UDP套接字,未连接 EDESTADDRREQ EDESTADDRREQ o

可以处于下列目的再次调用connect

  • 指定新的IP地址和端口号
  • 断开套接字

UDP报文大小的影响因素

  • UDP协议本身,UDP协议中有16位的UDP报文长度,那么UDP报文长度不能超过2^16=65536
    • UDP数据包的最大理论长度是2^16 - 1 - 8 - 20 = 65507字节
  • 以太网(Ethernet)数据帧的长度,数据链路层的MTU(最大传输单元)。
  • socket的UDP发送缓存区大小
  • UDP数据包理想长度:1500字节 – IP头(20字节) – UDP头(8字节) = 1472字节

UDP性能

当在一个未连接UDP套接字上调用sendto时,内核暂时连接该套接字,发送数据包,然后断开连接。

  • 连接套接字
  • 输出第一个数据包
  • 断开套接字连接
  • 连接套接字
  • 输出第二个数据包
  • 断开套接字连接

当应用程序知道子集要给同一目的地址发送多个数据报时,显示连接套接字效率更高,调用connect后调用两次write涉及内核执行如下步骤

  • 连接套接字
  • 输出第一个数据报
  • 输出第二个数据报

这种情况下,内核只复制一次含有目的IP地址和端口号的套接字地址结构,而sendto需要复制两次

对比TCP

  • 没有正面确认丢失分组重传重复分组检测分组排序
  • 无法动态调整发包
    • 没有窗口式流量控制
    • 没有慢启动和拥塞避免
  • 无法智能利用空闲带宽导致资源利用率低
    • UDP无法根据变化进行调整,发包过大或过小,从而导致带宽利用率低下,有效吞吐量较低

改进UDP的成本较高


  • 适合UDP
    • 对于组播或广播必须使用UDP。
    • 对于简单的请求-应答可以使用UDP
    • 大量不需要精确的数据:直播
    • 高通信实时性:NTP
  • 不适合UDP
    • 对于精确的大量数据传输不适合UDP(因为需要自己造TCP)

UDP丢包问题

  • UDP socket缓冲区满造成的UDP丢包
  • UDP socket缓冲区过小造成的UDP丢包
    • socket缓冲区过小无法容下该UDP报文,那么该报文就会丢失。
  • ARP缓存过期导致UDP丢包
    • 没有获取到MAC地址之前,用户发送出去的UDP数据包会被内核缓存到arp_queue这个队列中,默认最多缓存3个包

I/O超时

在套接字I/O操作上设置超时的方法有3种

  • 调用alarm
    • 超时时产生SLAGLRM信号
  • select中阻塞等待I/O(设置超时)
  • 使用SO_RCVTIMEOSO_SNDTIMEO
    • 仅适用套接字描述符

上述三个技术都适用与输入和输出操作,connect默认为75s超时,也不是不能用。select能在connect上设置超时的先决条件时相应的套接字处于非阻塞模式。

  • 输入操作
    • read:最普通的读
    • readv:分散读
    • recv:比read多个flag可进程一些控制
    • recvfrom:指定地址
    • recvmsg:通用的I/O读函数
  • 输出操作
    • write:最普通的写
    • writev:集中写
    • send:比write多个flag可进程一些控制
    • sendto:指定地址
    • sendmsg:通用的I/O写函数

UNIX域协议

用于单个主机上执行客户/服务器通信的一种方法(如:docker unix套接字)

  • 性能比TCP快几倍
  • 可用于不同进程之间传递描述符
  • 可以传递用户ID和组ID,提供您额外的安全验证措施
1
2
3
4
5
6
struct sockaddr_un {
// AF_LOCAL
sa_family_t sun_family;
// path
char sun_path[104];
}
  • socket
    • 创建套接字
  • unlink
    • 先删除这个路径名,防止已经存在
  • bind
    • 绑定

socketpair函数

创建两个随后连接起来的套接字,本函数仅适用UNIX套接字

1
2
#include <sys/socket.h>
int socketpair(int family, int type, int protocol, int sockfd[2]);
  • family: AF_LOCAL
  • type:
    • SOCK_STREAM:得到结果为流管道类型
    • SOCK_DGRAM数据报类型
  • protocol: 0
  • sockfd:返回套接字

要求说明

  • bind创建的路径名默认访问权限为0777
  • 关联的路径应该是一个绝对路径
  • connect中指定的路径名必须是一个当前绑定在某个打开的UNIX域套接字上的路径名
    • 他们套接字类型必须一致
  • connect相当于open已只写打开的权限
  • 与TCP套接字相似,都为进程提供一个无记录边界的字节流接口

UNIX系统提供了用于一个进程向任意其他进程传递任一打开的描述符的方法

  • 创建一个字节流的或数据包的UNIX套接字
  • 发送进程通过调用返回描述符的任一UNIX函数打开一个描述符
  • 发送进程创建一个msghdr结构,其中含有待传递的描述符
  • 接受进程调用recvmsg在来自步骤1的UNIX套接字上接受这个描述符

传递一个描述符并不是传递一个描述符号,而是涉及在接受进程中创建一个新的描述符,这个新的描述符和发送进程中的那个描述符指向内核中相同的文件表项

参考