前言
:为了让所学的计网知识融合于实际,让操作系统里的理论去满足工程需求,故通过借鉴30dayMakeServer的路线以及进行相应知识点的学习。
part1
首先我们要理解socket
为应用层和传输层提供应用编程接口(API)。
同层功能协议是水平交流,实际是
信息传输的时候是需要一个接口去完成对应信息的转换和传输,这里我们用的那个接口就是在Linux系统下使用的socket。
引入的头文件是
#include <sys/socket.h>
其中
socket(AF_INET, SOCK_STREAM, 0)
这三个参数含义分别为:
- 第一个参数:IP地址类型,AF_INET表示使用IPv4,如果使用IPv6请使用AF_INET6。
- 第二个参数:数据传输方式,SOCK_STREAM表示流格式、面向连接,多用于TCP。SOCK_DGRAM表示数据报格式、无连接,多用于UDP。
- 第三个参数:协议,0表示根据前面的两个参数自动推导协议类型。设置为IPPROTO_TCP和IPPTOTO_UDP,分别表示TCP和UDP。
每一个服务器都应该有一个唯一的标识符,而客户端要与其建立联系及传输信息,就要和这个标识符进行连接,
这个标识符由IP地址和网络端口组成,
客户端首先建立了socket这个应用编程接口,接下来要确定这个接口具体的连接对象的位置,这里我们使用
#include <arpa/inet.h> //这个头文件包含了<netinet/in.h>
这个头文件的,
这个结构体有三个子对象,
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
serv_addr.sin_port = htons(8888);
分别是设置地址族、IP地址和端口。
因为一个服务器的端口不应该只能提供给一个客户端,所以要将这个端口变成都可访问。
这里使用了一个函数,对socket这个接口进行泛化。
bind(sockfd, (sockaddr*)&serv_addr, sizeof(serv_addr));
有接口,并且都能让其他客户端去连接,接下来在服务器端就要加一个监听函数,来的确认运气情况。
listen
函数监听这个socket端口,这个函数的第二个参数是listen函数的最大监听队列长度,系统建议的最大值
SOMAXCONN
被定义为128。
listen(sockfd, SOMAXCONN);
要接受一个客户端连接,需要使用
accept
函数。对于每一个客户端,我们在接受连接时也需要保存客户端的socket地址信息,于是有以下代码:
struct sockaddr_in clnt_addr;
socklen_t clnt_addr_len = sizeof(clnt_addr);
bzero(&clnt_addr, sizeof(clnt_addr));
int clnt_sockfd = accept(sockfd, (sockaddr*)&clnt_addr, &clnt_addr_len);
printf("new client fd %d! IP: %s Port: %d\n", clnt_sockfd, inet_ntoa(clnt_addr.sin_addr), ntohs(clnt_addr.sin_port));
要注意和
accept
和
bind
的第三个参数有一点区别,对于
bind
只需要传入serv_addr的大小即可,而
accept
需要写入客户端socket长度,所以需要定义一个类型为
socklen_t
的变量,并传入这个变量的地址。另外,
accept
函数会阻塞当前程序,直到有一个客户端socket被接受后程序才会往下运行。
客户端的写法和服务器很相似,但实现功能不同,是有点不一样的地方,在建立好接口以及绑定之后,是不需要泛化接口,之后就通过一个函数使它和服务器端连接。
connect(sockfd, (sockaddr*)&serv_addr, sizeof(serv_addr));
这幅图就是服务器和客户端信息交换的流程图。
附上的代码是来自30dayMakeServer。
其GitHub地址:
https://github.com/yuesong-feng/30dayMakeCppServer
server部分:
#include <stdio.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <string.h>
int main() {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);//ip4/6 ,udp/tcp,0/??
struct sockaddr_in serv_addr;
bzero(&serv_addr, sizeof(serv_addr));//功能:置字节字符串s的前n个字节为零且包括‘\0’。
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
serv_addr.sin_port = htons(8888);
bind(sockfd, (sockaddr*)&serv_addr, sizeof(serv_addr));//第二个参数是一个指向特定协议的地址结构的指针,第三个参数是该地址结构的长度。对于TCP,调用bind函数可以指定一个端口号,或指定一个IP地址,也可以两者都指定,还可以都不指定。
listen(sockfd, SOMAXCONN);//第二个参数是一个指向特定协议的地址结构的指针,第三个参数是该地址结构的长度。对于TCP,调用bind函数可以指定一个端口号,或指定一个IP地址,也可以两者都指定,还可以都不指定。
struct sockaddr_in clnt_addr;
socklen_t clnt_addr_len = sizeof(clnt_addr);
bzero(&clnt_addr, sizeof(clnt_addr));
int clnt_sockfd = accept(sockfd, (sockaddr*)&clnt_addr, &clnt_addr_len);//要接受一个客户端连接,需要使用`accept`函数。对于每一个客户端,我们在接受连接时也需要保存客户端的socket地址信息
printf("new client fd %d! IP: %s Port: %d\n", clnt_sockfd, inet_ntoa(clnt_addr.sin_addr), ntohs(clnt_addr.sin_port));
return 0;
}
clinet部分:
#include <sys/socket.h>
#include <arpa/inet.h>
#include <string.h>
int main() {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in serv_addr;
bzero(&serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
serv_addr.sin_port = htons(8888);
//bind(sockfd, (sockaddr*)&serv_addr, sizeof(serv_addr)); 客户端不进行bind操作
connect(sockfd, (sockaddr*)&serv_addr, sizeof(serv_addr));
return 0;
}
part2
各种语言中,都有对异常的处理机制,这里我们就简单写一个函数来显示异常所在的地方,而不是捕获异常之后进行处理。
为了方便编码以及代码的可读性,可以封装一个错误处理函数:
void errif(bool condition, const char *errmsg){
if(condition){
perror(errmsg);
exit(EXIT_FAILURE);
}
}
第一个参数是是否发生错误,如果为真,则表示有错误发生,会调用
<stdio.h>
头文件中的
perror
,这个函数会打印出
errno
的实际意义,还会打印出我们传入的字符串,也就是第函数第二个参数,让我们很方便定位到程序出现错误的地方。然后使用
<stdlib.h>
中的
exit
函数让程序退出并返回一个预定义常量
EXIT_FAILURE
。
在使用的时候:
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
errif(sockfd == -1, "socket create error");
通过这种方式就能在遇到错误的时候很快找到。
对于所有的函数,我们都使用这种方式处理错误:
errif(bind(sockfd, (sockaddr*)&serv_addr, sizeof(serv_addr)) == -1, "socket bind error");
errif(listen(sockfd, SOMAXCONN) == -1, "socket listen error");
int clnt_sockfd = accept(sockfd, (sockaddr*)&clnt_addr, &clnt_addr_len);
errif(clnt_sockfd == -1, "socket accept error");
errif(connect(sockfd, (sockaddr*)&serv_addr, sizeof(serv_addr)) == -1, "socket connect error");
这只是简单的一种对错误的一种处理方式,这只是一个简单的处理。
part3
服务器会面向多个客户端,所以前面我们用到的bind函数,但这只是提供端口,怎么去处理多个服务并行?
方法很多,这里我用的epoll,接下来我会讲一下我对它的理解和使用,
这里参照的是
https://zhuanlan.zhihu.com/p/63179839,
https://cloud.tencent.com/developer/article/1816835,
从Linux源码看epoll可借鉴:https://my.oschina.net/alchemystar/blog/3008840
epoll实现的过程
数据经由网卡传送到内存(步骤②),然后网卡通过中断信号通知cpu有数据到达,cpu执行中断程序(步骤③)。此处的中断程序主要有两项功能,先将网络数据写入到对应socket的接收缓冲区里面(步骤④),再唤醒进程A(步骤⑤),重新将进程A放入工作队列中。
首先网卡接收网线传来的数据,通过硬件电路传输,之后写入内存上。
问题来了,如何确定接受到了数据?
这里我们就要讲一下epoll实现的又一个方法:中断,
在运行多个任务的时候,cpu是会有优先级的处理方式,优先级高的就会优先处理,一般情况下,硬件指令的优先级会高于软件。
一般而言,由硬件产生的信号需要cpu立马做出回应(不然数据可能就丢失),所以它的优先级很高。cpu理应中断掉正在执行的程序,去做出响应;当cpu完成对硬件的响应后,再重新执行用户程序。中断的过程如下图,和函数调用差不多。只不过函数调用是事先定好位置,而中断的位置由“信号”决定。
以键盘为例,当用户按下键盘某个按键时,键盘会给cpu的中断引脚发出一个高电平。cpu能够捕获这个信号,然后执行键盘中断程序。下图展示了各种硬件通过中断与cpu交互。
之后就是这么一个过程:
网卡将数据写入内存,同时网卡向cpu发送一个中断信号让cpu知道数据已经来了,之后进行进程调度来处理这一数据。
接下来我们思考两个问题
其一,如何同时监视多个socket的数据?
其二,操作系统如何知道网络数据对应于哪个socket?(以及处理)
为了更好的介绍epoll,这里我们先介绍select,
首先我们要建立多个监督socket的方式,
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...)
listen(s, ...)
int fds[] = 存放需要监听的socket
while(1){
int n = select(..., fds, ...)
for(int i=0; i < fds.count; i++){
if(FD_ISSET(fds[i], ...)){
//fds[i]的数据处理
}
}
}
这里是用一个数组fds,把所有的socket都放进去,
这一块就是来查看那一块是有数据的,如果存入中的socket中至少有一个是有数据的,则将存入他们的进程从等待态唤醒。
这个方法会有两个问题,查找没数据的时候需要遍历一次,进程就绪找出有数据的去处理,又要遍历一次。
详细一点:
select/poll模型。
情况1. 为了能够让一个进程,监控多个socket.
就把一个socket列表,传入到selector选择器上。
这样selector,循环遍历socket列表。
把就绪状态的 socket 选择出来,然后进行处理。
什么是就绪状态:假设socket的读缓冲区有可读的数据,那么此socket就处于就绪状态。
就绪状态的socket,可以这样处理,
当读缓冲区有数据时,就读数据。
当写缓冲区有空间可以写时,就写数据。
当ServerSocket有新建了连接时,就接收 clinetSocket ,并且把clinetSocket添加到seletor选择器中。
完成以上处理后,再次遍历socket列表,按照以上逻辑反复处理。
情况2. 遍历一次socket列表,没有一个socket处于就绪状态,那么进程A将阻塞。
具体流程:遍历socket列表,发现socket处于未就绪状态。那么就在该socket的等待队列中,添加进程A的引用。
当遍历完整个socket列表,却没有任何一个socket处于就绪状态。那么就把进程A从工作队列移除,让进程处于阻塞状态。
由于CPU每次执行完指令,都会检查是否有中断信号。
假设10S以后,socket列表中,有一个socket处于就绪状态。
那么网卡对应的硬件,会向CPU发一个中断信号。CPU收到一个中断信号,从而调用对应的中断程序。
此中断程序,会把进程A唤醒,加入到工作队列。
并且遍历socket列表,将进程A的引用,从所有的等待队列中移除。
selector再次遍历socket列表,把就绪的 socket选出来,然后进行读或者写的操作。
再来看等待队列的作用:就是当socket就绪时,能够通过等待队列的进程引用,找到对应的进程。
(ps:还有一个思考方面是从用户态和内核态思考,
如:关于select,最low的就是在用户代码中自旋实现所有阻塞socket的监听。但是每次判断socket是否产生数据,都涉及到用户态到内核态的切换。
于是select改进:将fd_set传入内核态,由内核判断是否有数据返回;
然后最low的只能使用自旋来时刻的去判断socket列表中是否有数据达到。
于是select改进:使用等待队列,让线程在没有资源时park(阻塞),当有数据到达时唤醒select线程,去处理socket。)
接下来我们就讲解epoll,
首先我们讲一下多路复用,
多路复用I/O:是指内核负责监听多个 I/O 流,当任何一个 I/O 流处于就绪状态(可读或可写)时都会通知进程,以便可以处理该 I/O 流上的数据。如 图1 所示:
包括select、poll也是为了实现这个,
其中epoll的实现方式是,
采用了一个红黑树,epoll 内部使用红黑树来保存所有监听的 socket,红黑树是一种平衡二叉树,添加和查找元素的时间复杂度为 O(log n),其结构如 图2 所示:
epoll 通过 socket 句柄来作为 key,把 socket 保存在红黑树中。如 图2 所示,每个节点中的数字代表着 socket 句柄。
把监听的 socket 保存在红黑树中的目的是,为了在修改监听 socket 的读写事件时,能够通过 socket 句柄快速找到对应的 socket 对象。
这样就加快了索引的速度。
第二个就是
就绪列表
select低效的另一个原因在于程序不知道哪些socket收到数据,只能一个个遍历。如果内核维护一个“就绪列表”,引用收到数据的socket,就能避免遍历。如下图所示,计算机共有三个socket,收到数据的sock2和sock3被rdlist(就绪列表)所引用。当进程被唤醒后,只要获取rdlist的内容,就能够知道哪些socket收到数据。
整体实现
epoll原理再解剖以及使用流程
首先是epoll_create创建一个eventpoll对象,并将其添加到文件系统中,和普通socket一样,也有等待队列(存储的是等待的进程),eventpoll对象也是文件系统中的一员,和socket一样,它也会有等待队列。
之后要对监视列表进行维护如:有新的socket要跟踪。
创建epoll对象后,可以用epoll_ctl添加或删除所要监听的socket。以添加socket为例,如下图,如果通过epoll_ctl添加sock1、sock2和sock3的监视,内核会将eventpoll添加到这三个socket的等待队列中。
以上准备好之后就可以工作了,当socket接收到了数据,那么中断程序会给eventPoll的rdlist就会对对应的socket添加引用,如图:
我们可以简单的讲这个理解为:socket和进程高频高数量的交互是需要一个媒介的,而eventpoll就是这个媒介。
这里我们就要思考一个问题,如果socket为空对应的进程应该怎么处理?
这里我们引用了一个epoll_wait函数。
当程序执行到epoll_wait时,如果rdlist已经引用了socket,那么epoll_wait直接返回,如果rdlist为空,阻塞进程。
我们先讲一下进程的阻塞和唤醒在此的实现,
假设计算机中正在运行进程A和进程B,在某时刻进程A运行到了epoll_wait语句。如下图所示,内核会将进程A放入eventpoll的等待队列中,阻塞进程。
当socket接收到数据,中断程序一方面修改rdlist,另一方面唤醒eventpoll等待队列中的进程,进程A再次进入运行状态(如下图)。也因为rdlist的存在,进程A可以知道哪些socket发生了变化。
这里要注意:
epoll不会让每个 socke t的等待队列都添加进程A引用,而是在等待队列,添加 eventPoll对象的引用。
当socket就绪时,中断程序会操作eventPoll,在eventPoll中的就绪列表(rdlist),添加scoket引用。
这样的话,进程A只需要不断循环遍历rdlist,从而获取就绪的socket。
从代码来看每次执行到epoll_wait,其实都是去遍历 rdlist。
如果rdlist为空,那么就阻塞进程。
当有socket处于就绪状态,也是发中断信号,再调用对应的中断程序。
此时中断程序,会把socket加到rdlist,然后唤醒进程。进程再去遍历rdlist,获取到就绪socket。
总之: poll是翻译轮询的意思,我们可以看到poll和epoll都有轮询的过程。
不同点在于:
poll轮询的是所有的socket。
而epoll只轮询就绪的socket。
练习实现代码:
用的是前面的已经放的三十天,
clint.cpp
#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <string.h>
#include <unistd.h>
#include "util.h"
#define BUFFER_SIZE 1024
int main() {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
errif(sockfd == -1, "socket create error");
struct sockaddr_in serv_addr;
bzero(&serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
serv_addr.sin_port = htons(8888);
errif(connect(sockfd, (sockaddr*)&serv_addr, sizeof(serv_addr)) == -1, "socket connect error");
while(true){
char buf[BUFFER_SIZE]; //在这个版本,buf大小必须大于或等于服务器端buf大小,不然会出错,想想为什么?
bzero(&buf, sizeof(buf));
scanf("%s", buf);
ssize_t write_bytes = write(sockfd, buf, sizeof(buf));
if(write_bytes == -1){
printf("socket already disconnected, can't write any more!\n");
break;
}
bzero(&buf, sizeof(buf));
ssize_t read_bytes = read(sockfd, buf, sizeof(buf));
if(read_bytes > 0){
printf("message from server: %s\n", buf);
}else if(read_bytes == 0){
printf("server socket disconnected!\n");
break;
}else if(read_bytes == -1){
close(sockfd);
errif(true, "socket read error");
}
}
close(sockfd);
return 0;
}
server.cpp
#include <stdio.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/epoll.h>
#include <errno.h>
#include "util.h"
#define MAX_EVENTS 1024
#define READ_BUFFER 1024
void setnonblocking(int fd){
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK);
}
int main() {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
errif(sockfd == -1, "socket create error");
struct sockaddr_in serv_addr;
bzero(&serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
serv_addr.sin_port = htons(8888);
errif(bind(sockfd, (sockaddr*)&serv_addr, sizeof(serv_addr)) == -1, "socket bind error");
errif(listen(sockfd, SOMAXCONN) == -1, "socket listen error");
int epfd = epoll_create1(0);
errif(epfd == -1, "epoll create error");
struct epoll_event events[MAX_EVENTS], ev;
bzero(&events, sizeof(events));
bzero(&ev, sizeof(ev));
ev.data.fd = sockfd;
ev.events = EPOLLIN | EPOLLET;
setnonblocking(sockfd);
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
while(true){
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
errif(nfds == -1, "epoll wait error");
for(int i = 0; i < nfds; ++i){
if(events[i].data.fd == sockfd){ //新客户端连接
struct sockaddr_in clnt_addr;
bzero(&clnt_addr, sizeof(clnt_addr));
socklen_t clnt_addr_len = sizeof(clnt_addr);
int clnt_sockfd = accept(sockfd, (sockaddr*)&clnt_addr, &clnt_addr_len);
errif(clnt_sockfd == -1, "socket accept error");
printf("new client fd %d! IP: %s Port: %d\n", clnt_sockfd, inet_ntoa(clnt_addr.sin_addr), ntohs(clnt_addr.sin_port));
bzero(&ev, sizeof(ev));
ev.data.fd = clnt_sockfd;
ev.events = EPOLLIN | EPOLLET;
setnonblocking(clnt_sockfd);
epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sockfd, &ev);
} else if(events[i].events & EPOLLIN){ //可读事件
char buf[READ_BUFFER];
while(true){ //由于使用非阻塞IO,读取客户端buffer,一次读取buf大小数据,直到全部读取完毕
bzero(&buf, sizeof(buf));
ssize_t bytes_read = read(events[i].data.fd, buf, sizeof(buf));
if(bytes_read > 0){
printf("message from client fd %d: %s\n", events[i].data.fd, buf);
write(events[i].data.fd, buf, sizeof(buf));
} else if(bytes_read == -1 && errno == EINTR){ //客户端正常中断、继续读取
printf("continue reading");
continue;
} else if(bytes_read == -1 && ((errno == EAGAIN) || (errno == EWOULDBLOCK))){//非阻塞IO,这个条件表示数据全部读取完毕
printf("finish reading once, errno: %d\n", errno);
break;
} else if(bytes_read == 0){ //EOF,客户端断开连接
printf("EOF, client fd %d disconnected\n", events[i].data.fd);
close(events[i].data.fd); //关闭socket会自动将文件描述符从epoll树上移除
break;
}
}
} else{ //其他事件,之后的版本实现
printf("something else happened\n");
}
}
}
close(sockfd);
return 0;
}
到此epoll的讲解就结束了。