C++Linux服务器学习之路——1

  • Post author:
  • Post category:linux



前言

:为了让所学的计网知识融合于实际,让操作系统里的理论去满足工程需求,故通过借鉴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的讲解就结束了。



版权声明:本文为m0_51174684原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。