LinuxC++网络编程学习笔记

主机字节序和网络字节序

字节在内存中的排列影响它实际的值,字节序分为大端序小端序。大端序指一个整数的高位存储在内存的低地址处,小端序指一个整数的高位存储在内存的高地址处。

现代PC大多采用小端序,因此小端序又被称为主机字节序

由于数据在两台使用不同字节序的主机之间进行传递是,接收到必然错误的解释了数据。解决的方法是:发送端总是把要发送的数据转化成大端序然后再发送,接受端明白对方传过来的数据总是采用大端序,所以接受端可以根据自身使用的字节序来决定是否对该数据进行转化。因此大端序也称为网络字节序

Linux提供了四个函数来完成主机字节序和网络字节序之间的转化:

1
2
3
4
5
6
7
8
#include <netinet/in.h>
// 一般用于转换ip地址
unsigned long int htonl(unsigned long int hostlong);
unsigned long int ntonl(unsigned long int netlong);

// 一般用于转换端口
unsigned long int htons(unsigned long int hostshort);
unsigned long int ntohs(unsigned long int netshort);

判断大小端:
1
2
3
4
5
6
7
8
union w{
int a;
char b;
}c;
int main() {
c.a = 1;
return c.b == 1; // 是否小端
}

解释一下原理:union联合体是共享内存的,c.a = 1 即最低位为1,其它都为0,小端模式低字节放在低地址中,所以b会被赋值为1。

socket套接字

1
2
3
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, ubt protocol);
  • domain: PF_INET -> IPv4, PF_INET6 -> IPv6, PF_UNIX -> UNIX本地协议族
  • type: SOCK_STREAM -> TCP协议,SOCK_DGRAM -> UDP协议
  • protocol: 一般都置位0,表示使用默认协议

调用成功返回一个socket文件描述符,其实就是一个数字,这个数字具有唯一性,并且一直有效直到你close()这个数字为止;失败返回-1并设置errno。

文件描述符:unix哲学——一切皆文件,我们把socket也看成是文件描述符,用它来收发数据。send(), recv()。

一旦连接成功建立,双方的通讯就只需要通过该文件描述符即可。

命名socket

创建socket时,指定了地址族,却并未指定该地址族中的哪个具体socket地址。我们称socket与socket地址绑定称为给socket命名。服务端只有命名后,客户端才知道如何连接它。客户端通常不需要命名,采用匿名,操作系统会自动分配给它socket地址。使用的函数为bind():

1
int bind(int sockfd, const struct* my_addr, socklen_t addrlen);
  • sockfd: socket文件描述符
  • my_addr: 这个地址将分配给未命名的sockfd文件描述符
  • addrlen: 该socket地址的长度

相同的ip地址的相同端口只能被 bind() 一次,bind成功返回0,失败返回-1并设置errno。

关于bind绑定失败的情况,还需要详谈。。。

监听socket

socket被命名后,还不能被马上接受客户连接,我们需要创建一个监听队列用以存放待处理的客户连接:

1
2
#include <sys/socket.h>
int listen(int sockfd, int backlog);

listen 用于监听端口,作用于TCP连接中的服务端。

对于一个调用listen()进行监听的套接字,操作系统会给这个套接字维护两个队列:

  • 未完成队列:当服务端收到客户端第一次握手发送的SYN包时(SYN_SENT状态),就会在未完成队列中创建一个跟该 SYN 包对应的一项新的套接字(通常由(服务器ip + port, 客户端ip +port)组成)
  • 已完成队列:三次握手完成后,连接变为ESTABISHED状态,从未完成队列进入已完成队列

backlog的含义:已完成队列和未完成队列条目之和不能超过backlog。
RTT:未完成队列中任意一项在未完成队列中停留的时间,这个时间取决于客户端和服务器。对于客户端,RTT为前两次握手时间;对于服务端,RTT为后两次握手时间。

客户端的Connect()其实在第二次握手结束后已经返回了。

细节:

  1. 如果两个队列之和已经达到最大上限,再有客户发送syn请求的话,这个请求会被服务器忽略;而客户端发现syn没有被回应,会重发请求包。
  2. 已完成队列中有客户端发来数据,但该套接字还未被accept函数取出,那么这个数据就会被保存在已连接的套接字的接收缓冲区中,接收的数据量取决于缓冲区有多大。

接受连接

accept() 函数,从已完成连接队列中的队首取出一项(已经完成三次握手连接的客户端socket值),返回给进程:

1
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  • sockfd 是执行过 listen 系统监听调用的 监听socket,只要服务端还在运行,那么它就应该一直存在(我们称已经处于ESTABISHED状态的客户端连接为 连接socket
  • addr 被用于接收远端 socket 地址,该地址的长度由addrlen指出

如果已完成队列为空,那么则会一直处于休眠等待状态,直到有内容时才唤醒。
accept返回的是对应TCP连接的套接字connfd。

如果建立连接后用户掉线,accept依然返回成功,因为它只负责从已完成队列中取出内容。

发起连接

客户端需要使用connect函数主动与服务器建立连接:

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

sockfd参数由socket系统调用返回一个socket,serv_addr参数是服务器监听的socket地址,addrlen则指定这个地址的长度。

connect成功时返回0。一旦成功建立连接,sockfd就唯一标识这个连接,客户端就可以通过读写sockfd来与服务器通信。失败返回-1并设置errno,常见的错误有:目的端口不存在,连接超时。

关闭连接

关闭连接实际上就是关闭该连接对应的socket:

1
int close(int fd);

fd是待关闭的socket,close并非总是立即关闭一个连接,而是将fd的引用计数减一。当引用计数为0时,才最终关闭连接。
在多进程中,一次fork会使父进程中打开的socket引用计数加一,因此必须在父进程和子进程中都对该socket执行close才能将连接关闭。

IO操作

分为两个阶段:数据准备阶段,内核空间复制会用户进程缓冲区阶段。

在Unix中,一切皆文件,文件即是一串二进制流,不论是socket,FIFO,管道,终端,都是文件,对这些流进行数据收发操作即是IO操作。系统调用 read 读入数据,调用 write 写入数据。我们如何知道操作哪个流?文件描述符,即fd,而fd就是一个整数,对这个整数操作即是对文件操作。创建一个socket,返回一个文件描述符,对socket操作即是对这个描述符操作。

阻塞/非阻塞

阻塞IO:调用某个函数,该函数卡在这里(进入休眠状态)等待一个事情发生,然后才继续执行,这种函数一般称为阻塞函数。
非阻塞IO:充分利用时间片,效率更高。不断的调用accept,recvfrom函数检查有没有数据到来。

同步/异步

异步IO:调用一个异步I/O函数,我们要给这个函数指定一个接收缓冲区和一个回调函数。调用后,该函数会立即返回。其余判断交给操作系统,判断数据是否到来,如果到来,操作系统会把数据拷贝到你所指定的缓冲区里,然后调用回调函数通知你。

异步和非阻塞的区别:

  • 非阻塞I/O需要不停调用I/O函数来检查数据是否到来,一旦数据到来,就必须卡在I/O函数里把内核缓冲区复制到用户缓冲区,然后才执行结束
  • 异步I/O只需要调用一次,然后你就可以去做别的事了,内核去帮你判断数据是否到来,最后通知你

同步I/O:
调用者必须亲自去查看事件有没有发生。
select,poll,epoll都可以认为属于同步IO。

  • 首先调用select函数判断有无数据到来,没有则卡在那里
  • select返回之后,调用recvfrom去取数据,取数据时也会卡一下

同步I/O看起来更麻烦一点,因为要调用两个函数才可以得到数据。但与阻塞式相比,优势在于I/O复用。

IO多路复用:
又称为I/O多路复用,将多个需要等待的socket(TCP连接)加入一个集合,select/poll/epoll等待这一堆的任何一个TCP连接有数据到来,再用具体的recvfrom去收。因此他的优势在于创建的线程数量更少,高并发下更省内存,CPU开销更少。

select

1
int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, struct timeval* timeout);
  • nfds 指定被监听文件描述符总数,通常设置为select监听的所有文件描述符中的最大值加1,因为文件描述符是从0开始计数的。
  1. 创建所关注的事件描述符集合。分为三大类事件描述符集合(读事件、写事件、异常发生事件)
  2. 等待事件发生,轮询这三个事件描述符集合里的每一个事件描述符,检查是否有相应的事件发生,如果有就执行。
  • select最大的缺陷就是单个进程所打开的FD是有一定限制的,fd_set是一个unsigned long型的数组,共16个元素,每一位对应一个fd,因此最多可以监听1024个。
  • 每次调用select都要向内核传入所有监听集合,这就需要频繁的从用户态到内核态拷贝数据。内核的系统调用会调用轮询函数,即便有fd就绪,也需要遍历所有监听集合,来判断哪个fd是可操作的。
  • 需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。

poll和select本质上没有区别,只是改为了链表存储,因此不会有最大监听个数的问题。

epoll技术

从Linux内核2.6开始引入,是一种典型的I/O多路复用技术,最大的特点就是支持高并发,完全没有会随着并发量提高而出现性能明显下降的情况,但是会造成一定内存消耗。

与select,poll不同的是,它使用一组函数来完成任务。epoll 把用户关心的文件描述符上的事件放在内核的一个事件表中,无需每次调用都要重复传入描述符,因此epoll需要一个额外的文件描述符,来唯一标示内核中的这个事件表,使用 epoll_create 来创建该文件描述符:

1
int epoll_create(int size);

size参数只是告诉内核事件表需要多大,还并不起作用。返回的文件描述符讲称为其它所有epoll系统调用函数的第一个参数,以指定要访问的内核事件表。

具体:创建一个eventpoll结构体对象,创建了一棵红黑树和一个双向链表,其中rbr指向该红黑树的根,rdlist指向该双向链表的头节点

使用 epoll_ctl 来操作内核事件表:

1
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

event 指定事件信息,fd为要操作的文件描述符(accept得到),op指定操作类型:

  • ADD,往事件表中注册fd上的事件,相当于向红黑树添加一个节点,key值为客户端连接产生的fd,如果已经存在该节点,则直接报错
  • MOD,修改fd上的注册事件,修改某个节点
  • DEL,删除fd上的注册事件,删除某个节点

events可以是以下几个宏的集合:

  • EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
  • EPOLLOUT:表示对应的文件描述符可以写;
  • EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
  • EPOLLERR:表示对应的文件描述符发生错误;
  • EPOLLHUP:表示对应的文件描述符被挂断;
  • EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
  • EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里

因此,红黑树上的节点来自于 epoll_ctl 操作。

当事件发生时,我们需要通过 epoll_wait 函数来得到操作系统的通知。

1
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);

双向链表中存放的是所有有事件/数据的请求,epoll_wait 遍历双向链表,把双向链表里面的数据拷贝出去,然后移除。

需要注意的是,在红黑树中的节点并不会被删除。实际上用的是一个结构体同时维护了红黑树和双向链表的节点内容:红黑树拥有指向父节点,子节点的指针,双向链表拥有指向上一个和下一个结点的指针。这种优秀的设计使得在删除双向链表的结点时并不会对红黑树产生影响,而是相互独立的,但维护的时候又可以一起维护,使得一个节点既可以作为红黑树节点,也可以作为双向链表节点,从而大大减少了内存浪费。

  • epfd 为 epoll_create 返回的对象描述符
  • events指向一个数组,最大长度为 maxevents,表示此次 epoll_wait 调用最多可以收集到 maxevents 个(双向链表中)已经准备好的读写事件。

什么时候内核会向双向链表中增加节点呢?

  • 客户端完成三次握手 ——> 服务器需要accept()
  • 当客户端关闭连接 ——> 服务器也要调用close()关闭
  • 客户端发送数据来 ——> 服务器需要调用read(),recv()函数来收数据
  • 当可以发送数据时 ——> 服务器调用send(),write()

表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。

select低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善。

LT 和 ET 模式

epoll对文件描述符的操作有两种模式:
LT:水平触发,低速效率较低,默认模式,如果这个事件没有被处理完,就会被一直触发
ET:边沿触发,高速模式,效率高,只会被触发一次,但代价是编码难度加大,对与写事件,ET模式更高效

为什么ET模式事件只触发一次:ET模式事件只会被扔到双向链表一次,被 epoll_wait 取出后销毁。

为什么LT模式事件会触发多次:如果事件没有处理完,就会被多次扔进双向链表。

如果收发数据包没有具体格式,可以考虑用ET模式。

底层角度:
网卡收到包后,会把接口设置一个电势差,驱动层会去一直扫描网卡接口的电势差。

LT:水平触发,有电势差存在就触发,所以没读完就会一直触发中断事件。
ET:边缘触发,发现电势差存在之后,先reset电势差,然后触发中断事件。所以不清空的话以后也不会再通知了。

TCP粘包

客户端粘包:短时间内多次send,客户端有 Nagle 优化算法,直接合并成一个数据包发送出去,导致粘包。因此只要关闭 Nagle 算法,就能解决客户端粘包问题。

服务端粘包:无论客户端是否粘包,都避免不了服务端都会粘包。两次recv之间需要时间,但如果这个时间内多个包来了,则这多个包可能就被第二次recv全部收走,导致一次recv就收走了所有的包。

解决粘包:把几个包一个一个拆出来,能拆一个是一个。
如何拆包?每一个收发的数据包都遵循包头+包体的格式,包头固定【10个字节】,其中有一个变量记录整个包【包头+包体】的长度。这样就知道了包体的长度,然后只收包体长度一样多的字节即可。这样就收到一个完整的数据包。

大量time_wait?

如果服务器主动关闭大量连接,那么会出现大量的资源,端口占用(time_wait),需要等待2MSL才会释放资源。一个比较好的建议是不要让服务端去主动关闭,而把关闭主动权交给客户端。
在实际业务中,长连接对应的并发量不会很高,无需考虑time_wait。
服务端作为提供服务的一方,一般是很少会去主动断开连接的,因此出现大量time_wait一般只能是代码出现了bug。解决办法是允许socket被重用或者缩短MSL时间(1MSL=2mins)。

大量close_wait:问题出现在被接收方,recv返回0没有及时close,一般是代码有问题。

作者

Benboby

发布于

2021-02-02

更新于

2021-03-22

许可协议

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×