前面的进程间通信都是在一个电脑上进行的,但是网络是跨电脑进行的,这就可能因为电脑底层的不同而导致通信出了问题,因此需要消除这些不同。
在各种计算机体系结构中,对于字节、字等的存储机制有所不同,因而引发了计算机通信领域中一个很重要的问题,即通信双方交流的信息单元(比特、字节、字、双字等等)应该以什么样的顺序进行传送。如果不达成一致的规则,通信双方将无法进行正确的编码/译码从而导致通信失败。
字节序,顾名思义字节的顺序,就是大于一个字节类型的数据在内存中的存放顺序
字节序分为大端字节序(Big-Endian)和小端字节序(Little-Endian)。例如对于十六进制0x01020304
,其共四个字节
0x 01 02 03 04
0x 04 03 02 01
(大部分计算机采用小端字节序)为了解决发收双方可能存在的字节序问题,因此引入网络字节顺序的概念:
网络宁节顺序是TCP/IP中规定好的一种数据表示格式,它与具体的CPU类型、操作系统等无关,从而可以保证数据在不同主机之间传输时能够被正确解释,网络字节顺序采用大端排序方式。
因此 ,网络通信时,需要将主机字节序转换成网络字节序(大端),另外一段获取到数据以后根据情况将网络字节序转换成主机字节序。
// 转换端口(16位2个字节)
uint16_t htons(uint16_t hostshort); // 主机字节序 - 网络字节序(host->network-short)
uint16_t ntohs(uint16_t netshort); // 网络字节序 - 主机字节序
// 转IP(32位4个字节)
uint32_t htonl(uint32_t hostlong); // 主机字节序 - 网络字节序(host->network-long)
uint32_t ntohl(uint32_t netlong); // 网络字节序 - 主机字节序
socket地址其实是一个结构体,封装端口号和IP等信息。后面的socket相关的api中需要使用到这个socket地址。
#include
struct sockaddr_storage
{
//16位的地址类型
sa_family_t sa_family;
unsigned long int __ss_align;//对齐用的
//这个就是下面所说的sa_data(14字节的地址数据)
char __ss_padding[ 128 - sizeof(__ss_align) ];
};
typedef unsigned short int sa_family_t;
sa_family
成员表示地址族的类型(sa_family_t)。地址族类型通常与协议族类型对应。常见的协议族(protocol family,也称 domain)和对应的地址族入下所示:(两者在实际中,经常混用)sa_data
成员用于存放 socket 地址值。但是,不同的协议族的地址值具有不同的含义和长度,如下所示:很多网络编程函数诞生早于 IPv4 协议,那时候都使用的是 struct sockaddr 结构体,为了向前兼容,现sockaddr 退化成了(void *)的作用,传递一个地址给函数,至于这个函数是 sockaddr_in 还是sockaddr_in6,由地址族确定,然后函数内部再强制类型转化为所需的地址类型。
// TCP/IP 协议族有 sockaddr_in 和 sockaddr_in6 两个专用的 socket 地址结构体,它们分别用于 IPv4 和 IPv6:
#include
struct sockaddr_in
{
sa_family_t sin_family; /* __SOCKADDR_COMMON(sin_) */
in_port_t sin_port; /* Port number. */
struct in_addr sin_addr; /* Internet address. */
/* Pad to size of `struct sockaddr'. */
unsigned char sin_zero[sizeof (struct sockaddr) - __SOCKADDR_COMMON_SIZE - sizeof (in_port_t) - sizeof (struct in_addr)];
};
struct in_addr
{
in_addr_t s_addr;
};
struct sockaddr_in6
{
sa_family_t sin6_family;
in_port_t sin6_port; /* Transport layer port # */
uint32_t sin6_flowinfo; /* IPv6 flow information */
struct in6_addr sin6_addr; /* IPv6 address */
uint32_t sin6_scope_id; /* IPv6 scope-id */
};
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;
typedef uint16_t in_port_t;
typedef uint32_t in_addr_t;
#define __SOCKADDR_COMMON_SIZE (sizeof (unsigned short int))
所有专用 socket 地址(以及 sockaddr_storage)类型的变量在实际使用时都需要转化为通用 socket 地址类型 sockaddr(强制转化即可),因为所有 socket 编程接口使用的地址参数类型都是 sockaddr
人们习惯用可读性好的字符串来表示 IP 地址,但编程中我们需要先把它们转化为整数(二进制数)方能使用。而记录日志时则相反,我们要把整数表示的 IP 地址转化为可读的字符串。
#include
// p:点分十进制的IP字符串,n:表示network,网络字节序的整数
int inet_pton(int af, const char *src, void *dst);
//返回值1表示成功,0非法,-1失败
// 将网络字节序的整数,转换成点分十进制的IP地址字符串
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
//返回值:返回转换后的数据的地址(字符串),和 dst 是一样的
#include
#include
#include // 包含了这个头文件,上面两个就可以省略
创建一个套接字
int socket(int domain, int type, int protocol);
// 成功:返回文件描述符,操作的就是内核缓冲区。
//失败:返回-1
绑定,将fd 和本地的IP + 端口进行绑定
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); // socket命名
//成功返回0,失败返回-1
//先创建一个socoket地址来用
struct sockaddr_in saddr;
saddr.sin_family = AF_INET;
//可以将本机ip转化到容器中
//但这个不常用,因为一台机器往往有多个网卡,可能存在多个ip
//inet_pton(AF_INET, "192.168.193.128", saddr.sin_addr.s_addr);
saddr.sin_addr.s_addr = htonl(INADDR_ANY); // 0.0.0.0,表示监听来到本机的所有ip,偷懒小做法
//假设端口为9999
saddr.sin_port = htons(9999);
int ret = bind(lfd, (struct sockaddr *)&saddr, sizeof(saddr));
assert(ret >= 0);
监听这个socket上的连接
int listen(int sockfd, int backlog);
// 与backlog相关联的文件在/proc/sys/net/core/somaxconn
//成功返回0,失败返回-1
listen
会创建一个未连接的队列和一个已连接的队列,而这个参数就是去指定这两个队列里未连接的和已经连接的和的最大值。一般指定5
即可,因为accept会再已经连接的队列中抽出连接来。接收客户端连接,默认是一个阻塞的函数,阻塞等待客户端连接
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
//成功则返回用于通信的文件描述符
//失败返回-1
struct sockaddr_in clientaddr;
int len = sizeof(clientaddr);
int cfd = accept(lfd, (struct sockaddr *)&clientaddr, &len);
if(cfd == -1) {
perror("accept");
exit(-1);
}
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
//返回值:成功 0, 失败 -1
ssize_t write(int fd, const void *buf, size_t count); // 写数据
ssize_t read(int fd, void *buf, size_t count); // 读数据
要实现TCP通信服务器处理并发的任务,使用多线程或者多进程来解决。
accept()的应用部分
)accpet
,因此不可以主动wait
去阻塞回收。只能通过信号
来解决回收子进程的功能。
struct sigaction act;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
act.sa_handler = recyleChild;
// 注册信号捕捉
sigaction(SIGCHLD, &act, NULL);
void recyleChild(int arg) {
//这里的while是为了一次能够回收多个!
while(1) {
int ret = waitpid(-1, NULL, WNOHANG);
if(ret == -1) {
// 所有的子进程都回收了
break;
}else if(ret == 0) {
// 还有子进程活着
break;
} else if(ret > 0){
// 被回收了
printf("子进程 %d 被回收了\n", ret);
}
}
}
continue
int cfd = accept(lfd, (struct sockaddr*)&cliaddr, &len);
if(cfd == -1) {
if(errno == EINTR) {
continue;
}
perror("accept");
exit(-1);
其实主要流程跟进程并发一模一样。
【需要注意的细节】
pthread_create(&pinfo->tid, NULL, working, pinfo);
第四个值只有一个,因此想要传入多个参数需要定义一个结构体来承接。
memcpy(&pinfo->addr, &cliaddr, len);
pthread_detach
当 TCP 连接中 A 向 B 发送 FIN 请求关闭,另一端 B 回应 ACK 之后(A 端进入 FIN_WAIT_2状态),并没有立即发送 FIN 给 A,A 方处于半连接状态(半开关),此时A 可以接收 B 发送的数据,但是 A 已经不能再向 B 发送数据。
从程序的角度,可以使用 API 来控制实现半连接状态
#include
int shutdown(int sockfd, int how);
使用 close 中止一个连接,但它只是减少描述符的引用计数,并不直接关闭连接,只有当描述符的引用计数为 0 时才关闭连接。shutdown 不考虑描述符的引用计数,直接关闭描述符。也可选择中止一个方向的连接,只中止读或只中止写。
【注意】
如果有多个进程共享一个套接字,close 每被调用一次,计数减 1 ,直到计数为 0 时,也就是所用进程都调用了 close,套接字将被释放。
在多进程中如果一个进程调用了 shutdown(sfd, SHUT_RDWR) 后,其它的进程将无法进行通信。但如果一个进程 close(sfd) 将不会影响到其它进程。
端口复用最常用的用途是:
#include
#include
// 设置套接字的属性(不仅仅能设置端口复用)
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
【应用】
端口复用,设置的时机是在服务器绑定端口之前。
int optval = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEPORT, &optval, sizeof(optval));
bind(...);
【解释】此处的I/O并不是传统的输入和输出,而是对套接字sockfd中的读和写缓冲区进行的I/O操作。
I/O多路复用使得程序能同时监听多个文件描述符,能够提高程序的性能,Linux 下实现 I/O 多路复用的系统调用主要有 select
、poll
和 epoll
。
先看一下之前的服务器工作的阻塞IO模型,前面提到的多个线程/进程,当遇到读/写以及连接socket时,都会发生阻塞。
但如果仅仅非阻塞而去轮询的话,虽然提高了程序的执行效率,但是需要占用更多的CPU和系统资源。因此IO多路转接技术应运而生!
如何使用:
fd_set
结构体,并通过FD_SET
函数,将描述符加入列表】select
),监听该列表中的文件描述符,直到这些描述符中的一个或者多个进行I/O操作时,该函数才返回。
在返回时,它会告诉进程有多少(哪些)描述符要进行I/O操作。
// sizeof(fd_set) = 128个字节 1024位
#include
#include
#include
#include
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
//返回值若为-1,则失败;若>0,则为检测的集合中有n个文件描述符发生了变化
sizeof(fd_set) = 128个字节 1024位
传入
是为了让函数知道需要监听哪些描述符,传出
是为了接下来去检测那些描述符发生了读操作)NULL
)NULL
) struct timeval {
long tv_sec; /* seconds */
long tv_usec; /* microseconds */
};```
NULL
)【应用时需要注意的细节】
因为rdset
是传入传出参数,因此我们把需要监听的位置置为1
,调用select
函数后,该函数会将没有发生读的位置置为0
,此时我们如果再重新将rdset
传入进select
,那么应该监听的位置,其就不监听了。
因此,在使用rdset
时,常初始化是一个,需要额外拷贝一个tem
用来当传出参数以来后序检测哪个文件描述符发生了读操作。
【初始化监听列表】
// fd_set一共有1024 bit, 全部初始化为0(清空列表)
void FD_ZERO(fd_set *set);
【将描述符加入监听列表】
void FD_SET(int fd, fd_set *set);
【断开连接后需要清除】
// 将参数文件描述符fd对应的标志位设置为0(clear)
void FD_CLR(int fd, fd_set *set);
【遍历寻找需要读的文件描述符】
// 判断fd对应的标志位是0还是1, 返回值 : fd对应的标志位的值,如果是0返回0, 是1返回1
int FD_ISSET(int fd, fd_set *set);
// 将参数文件描述符fd 对应的标志位,设置为1
与select
使用方法一样,不过是它的改进版。首先改进的是其监听队列的集合(与上面的fdset一样,只不过这个数组储存的内容更多):
#include
struct pollfd {
int fd; /* 委托内核检测的文件描述符 */
short events; /* 委托内核检测文件描述符的什么事件 */
short revents; /* 文件描述符实际发生的事件 */
};
struct pollfd myfd;
myfd.fd = 5;//监听5文件描述符
myfd.events = POLLIN | POLLOUT; //同时委托内核进行读写的监听操作
【poll函数】
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
//返回值:-1失败;>0则成功,n表示检测到集合中有n个文件描述符发生变化
重用
问题[详见epoll的细节])【初始化】需要人为初始化,好分辩哪些位置是还没放入文件描述符的,以及断开连接的位置。:
//此处1024可以更大
struct pollfd fds[1024];
for(int i = 0; i < 1024; i++) {
fds[i].fd = -1;
fds[i].events = POLLIN;//以监听读为例
}
【遍历查看操作】这个也是位操作的,例如查看数组中第一个放入的文件描述符是否发生了读操作:if(fds[0].revents & POLLIN)
【加入监听队列操作】
// 将新的文件描述符加入到集合中
for(int i = 1; i < 1024; i++) {
if(fds[i].fd == -1) {
fds[i].fd = cfd;
fds[i].events = POLLIN;
break;
}
}
只剩下select缺点的前两条了:
epoll_create()
去创建一个epoll的实例。而这个epoll
的实例并不在用户区,而是在内核区(是一个eventpoll
的结构体,里面有两个关键成员rbr
和rdlist
,这样就不用用户区的数组转内核区监听,直接内核区的结构了!)
rbr
是记录需要监听的文件描述符的集合,底层不同于前面两个由数组组成,这个是由红黑树实现的。rdlist
是就绪列表,是那些已经发生事件(读或者写)了。这个是由双向链表实现的。eventpoll
进行操作。因此我们需要创建一个检测事件的结构体epoll_event
,并修改其两个成员epev.events = EPOLLIN;//以读监听为例
和epev.data.fd = lfd;
(放入监听文件描述符)epoll_ctl()
,将文件描述符加入监听队列(加到上面的rbr
) ,但此时并不会开始检测epoll_wait()
,内核开始检测(若有发生事件的,内核就会将rbr
中的描述符送给rdlist
,最终由rdlist
递给用户)。epoll_wait()
传出参数来将就绪事件
填上数组,并且该函数会返回一个n,因此此时直接for(i = 0; i < n; ++i)
挨个处理就绪事件即可
.data.fd
来区分是哪个文件描述符。.events
来区分是哪个事件,例如查看写事件就是if(epevs[i].events & EPOLLOUT)
。typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
常见的Epoll检测事件:
fd
设置为非阻塞——通过fcntl
的一系列操作)【epoll函数】
#include
// 创建一个新的epoll实例。
//在内核中创建了一个数据,这个数据中有两个比较重要的数据,
//一个是需要检测的文件描述符的信息(红黑树)
//还有一个是就绪列表,存放检测到数据发送改变的文件描述符信息(双向链表)
int epoll_create(int size);
//返回值-1则失败,> 0则成功,返回操作epoll实例的文件描述符
【epoll实例管理】添加文件描述符信息,删除信息,修改信息(如:由监听读改为写)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
【epoll开始检测】
// 检测函数
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
//失败返回-1,成功,则返回发送变化的文件描述符的个数 > 0
LT(level - triggered)是缺省(即默认)的工作方式,并且同时支持 block 和 no-block socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的 fd 进行 IO 操作。只要你没读/写完,或者你不作任何操作,内核还是会继续通知你的【在epoll_wait()
那里告诉你】。
【实现】 默认就是LT触发
是高速工作方式,只支持 no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你【在epoll_wait()
那里告诉你】。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了。但是请注意,如果一直不对这个 fd 作 IO 操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)。
【注意】epoll工作在 ET 模式的时候,必须使用非阻塞套接口(是文件描述符fd,而不是wait那里,wait那里一般都是阻塞的!),以避免由于一个文件句柄(win下叫句柄,linux下叫文件描述符)的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
【实现】在事件中,设置上EPOLLET
【ET相比LT的好处】
【ET的缺点】在使用readn函数要读取500B数据时,假设此时服务端只发来了200B数据,readn函数会发生阻塞,等待剩下300B数据的到来,此时如果采用边沿触发模式,readn会在下一次数据到来前阻塞,而readn函数阻塞等待,导致epoll_wait函数无法监听下次数据的到来,readn又在阻塞等下次数据到来,最后的结果就是形成“死锁”。因此,只能工作在非阻塞的情况。
因此此时就要引进阻塞非阻塞了。阻塞就是办完再到下一个,否则不动。而非阻塞是办不完先去干别的,然后轮询。这个轮询的操作就是要用到epoll了,那么epoll下的LT和ET有以下情况:
【LT水平触发模式】
LT 模式 (水平触发)
假设委托内核检测读事件 -> 检测fd的读缓冲区
epoll_wait()
那里告诉你】
ET 模式(边沿触发)
假设委托内核检测读事件 -> 检测fd的读缓冲区
epoll_wait()
那里告诉你】
一个典型的网络IO接口调用,分为以下两个阶段:
【操作系统的TCP接收缓冲区】
数据就绪:根据系统IO操作的就绪状态(以使用recv
服务端接收函数为例)
recv
,并根据其返回值判断是重新循环or直接退出循环or去处理读取到的数据【应用程序去读写】
数据读写:根据应用程序与内核的交互方式(以使用recv
服务端接收函数为例解释)
recv
时,会等待执行完这个函数,而执行这个函数时,系统会把TCP接收缓冲区的数据存入到recv
函数的传出参数buf
中,而在没完全存入buf
中,即函数没调用之前,应用不可以执行下面的函数,因此这是同步read
或者write
依旧是同步函数实现的recv
功能,假设有一个异步IO的接口(例如linux中的aio_read
函数),那么应用程序此时可以通过这个接口,将sockfd、buf以及通知方式传递给系统,自己继续向下执行,当系统完成了IO后,来通知应用程序。在处理IO的时候,阻塞和非阻塞都是同步IO,只有使用了特殊的API才是同步IO
调用者调用了某个函数,等待这个函数返回,期间什么也不做,不停的去检查这个函数有没有返回,必须等这个函数返回才能进行下一步动作。
非阻塞等待,每隔一段时间就去检测IO事件是否就绪。没有就绪就可以做其他事。非阻塞I/O执行系统调用总是立即返回,不管事件是否已经发生,若事件没有发生,则返回-1,此时可以根据 errno 区分这两种情况,对于accept,recv 和 send,事件未发生时,errno 通常被设置成 EAGAIN。
【优点】发现没准备好,可以先去做其他事情,然后再查询其状态。
Linux 用 select/poll/epoll
函数实现 IO 复用模型,这些函数也会使进程阻塞,但是和阻塞IO所不同的是这些函数可以同时阻塞多个IO操作。而且可以同时对多个读操作、写操作的IO函数进行检测。直到有数据可读或可写时,才真正调用IO操作函数。
【优点】
Linux 用套接口进行信号驱动 IO,安装一个信号处理函数,进程继续运行并不阻塞,当IO事件就绪,进程收到SIGIO
信号,然后处理 IO 事件。
【优点】
Linux中,可以调用 aio_read
函数告诉内核描述字缓冲区指针和缓冲区的大小、文件偏移及通知的方式,然后立即返回,当内核将数据拷贝到缓冲区后,再通知应用程序。
虽然服务器程序种类繁多,但其基本框架都一样,不同之处在于逻辑处理。
要求主线程(I/O处理单元)只负责监听文件描述符上是否有事件发生,有的话就立即将该事件通知工作线程(逻辑单元),将 socket 可读可写事件放入请求队列,交给工作线程处理。
除此之外,主线程不做任何其他实质性的工作。读写数据,接受新的连接,以及处理客户请求均在工作线程中完成。
使用同步 I/O(以 epoll_wait 为例)实现的 Reactor 模式的工作流程是:
Proactor 模式将所有 I/O 操作都交给主线程和内核来处理(进行读、写),工作线程仅仅负责业务逻辑。
同步模拟Proactor就是主线程通过一个EPOLL
对象来实现读、写、监听的功能,因为一个epoll对象监听一个事件组,根据事件组中出现的描述符是否是监听可以判断是否是连接;根据事件组里面的events标志位,可以判断是发生了读事件还是写事件。
http_conn
类型)epoll_wait
函数等待结果accept
连接客户端,并将新的客户进行数据初始化,并放入第三步的数组中EPOLLRDHUP
、EPOLLHUP
、EPOLLERR
则是对方异常断开或者错误等事件,因此需要关闭连接。
EPOLLIN
,则表示有需要读的事件发生,因此要一次性把所有数据都读完,读完后,将该套接字传给线程池EPOLLOUT
,则表示有需要写的事件发生,因此要一次性把所有数据都写完,写完后,将该套接字传给线程池线程池是由服务器预先创建的一组子线程。线程池中的所有子线程都运行着相同的代码。
当有新的任务到来时,主线程将通过某种方式选择线程池中的某一个子线程来为之服务。相比与动态的创建子线程,选择一个已经存在的子线程的代价显然要小得多。至于主线程选择哪个子线程来为新任务服务,则有多种方式:
如果是CPU密集型应用,则线程池大小设置为N+1
;
如果是IO密集型应用,则线程池大小设置为2N+1
。
任务一般可分为:CPU密集型、IO密集型、混合型,对于不同类型的任务需要分配不同大小的线程池。
CPU密集型任务 尽量使用较小的线程池,一般为CPU核心数+1。 因为CPU密集型任务使得CPU使用率很高,若开过多的线程数,只能增加上下文切换的次数,因此会带来额外的开销。
IO密集型任务 可以使用稍大的线程池,一般为2*CPU核心数。 IO密集型任务CPU使用率并不高,因此可以让CPU在等待IO的时候去处理别的任务,充分利用CPU时间。
混合型任务可以将任务分成IO密集型和CPU密集型任务,然后分别用不同的线程池去处理。 只要分完之后两个任务的执行时间相差不大,那么就会比串行执行来的高效。 因为如果划分之后两个任务执行时间相差甚远,那么先执行完的任务就要等后执行完的任务,最终的时间仍然取决于后执行完的任务,而且还要加上任务拆分与合并的开销,得不偿失。
静态资源
。post
函数wait
函数,等能向下执行就(上锁,取出,删除,解锁),然后执行任务处理即可。pthread_create
函数来实现的,而这个函数中所传人的子线程执行的函数需要是静态函数
,但静态函数只能访问静态成员,为了解决这个问题,这里在pthread_create
中传入this
指针充当变量,这样可以将this
指针类型强转会对象,随后使用其私有属性。本笔记是针对牛客的高境老师的《第四章Linux网络开发》内容书写的。默默感谢一下高老师,确实很细致。