zoukankan      html  css  js  c++  java
  • 在线服务之socket编程科普

    简介

    本篇文章是介绍一个典型的在线C++服务的最底层socket管理是如何实现的。

    文章会从一个最简单的利用socket编程基础API的一个小程序开始,逐步引入现在典型的select,epoll机制,并附上相关demo代码。

    socket编程

    基于TCP协议的网络程序

    TCP协议通讯流程如下图:

    TCP协议通讯流程

    最简单的TCP网络程序

    服务端:

    /*server.c*/
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <unistd.h>
    #include <sys/socket.h>
    #include <netinet/in.h>
    
    #define MAXLINE 80
    #define SERV_PORT 8000
    
    int main(void) {
        struct sockaddr_in servaddr, cliaddr;
        socklen_t cliaddr_len;
        int listenfd, connfd;
        char buf[MAXLINE];
        char str[INET_ADDRSTRLEN];
        int i, n;
    
        // 第一个系统调用, 建立监听句柄
        // 第一个参数, AF_INET代表IPv4, AF_INET6代表IPv6, AF_UNIX代表Unix Domain Socket(本地文件)
        // 第二个参数,  SOCK_STREAM代表TCP, SOCK_DGRAM代表UDP
        listenfd = socket(AF_INET, SOCK_STREAM, 0);
    
        bzero(&servaddr, sizeof(servaddr));
        // 同socket()系统调用第一个参数
        servaddr.sin_family = AF_INET;
        // 同一台机器可能有多个网卡, 一个网卡也可以绑定多个IP, 代表所有IP都绑定
        servaddr.sin_addr.s_addr = htol(INADDR_ANY);
        // 端口, 网络协议都是小端序, 要用这个htons系列函数将host编码转为net编码, 
        // intel机器都是小端, 所以一般都直接返回
        servaddr.sin_port = htons(SERV_PORT);
    
        // 第二个系统调用, 将句柄跟对应端口绑定起来
        // 第一个参数, 刚刚同构socket建立的句柄
        // 第二个&第三个参数, 需要绑定的端口信息
        bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
        // 开始监听, 20代表如果一个socket还没有被accept走的话, 可以临时挂着等待被处理的状态
        listen(listenfd, 20);
    
        printf("Acceptin connections ...
    ");
    
        while(1) {
            cliaddr_len = sizeof(cliaddr);
            // 获取客户端的连接句柄, 如果没链接, 会阻塞等待客户端链接
            // 传出客户端句柄, 客户端连接相关信息
            connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &cliaddr_len);
    
            n = read(connfd, buf, MAX_LINE);
            printf("received from %s at PORT %d
    ", 
                inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)),
                ntohs(cliaddr.sin_port));
    
            for (i=0; i<n; ++i) {
                buf[i] = toupper(buf[i]);
            }
            write(connfd, buf, n);
            close(connfd);
        }
    }
    

    客户端:

    /* client.c */
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <unistd.h>
    #include <sys/socket.h>
    #include <netinet/in.h>
    
    #define MAXLINE 80
    #define SERV_PORT 8000
    
    int main(int argc, char *argv[])
    {
        struct sockaddr_in servaddr;
        char buf[MAXLINE];
        int sockfd, n;
        char *str;
        
        if (argc != 2) {
            fputs("usage: ./client message
    ", stderr);
            exit(1);
        }
        str = argv[1];
        
        // 跟服务器一样, 建立socket句柄
        sockfd = socket(AF_INET, SOCK_STREAM, 0);
    
        bzero(&servaddr, sizeof(servaddr));
        servaddr.sin_family = AF_INET;
        inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr);
        servaddr.sin_port = htons(SERV_PORT);
        
        // 跟服务器对应的地址和端口号建立连接
        // connect()和bind()函数的参数是一样的, 只是connect是连接别人, bind是绑定自己
        // 客户端对应的socket不需要分配端口, 内核会自动为该句柄分配端口
        connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
    
        // 发送数据
        write(sockfd, str, strlen(str));
    
        // 读取数据
        n = read(sockfd, buf, MAXLINE);
        printf("Response from server:
    ");
        write(STDOUT_FILENO, buf, n);
    
        close(sockfd);
        return 0;
    }
    

    简单程序逐步优化

    假设在如上的client.c中, 将write到close这一段修改为:

    while (fgets(buf, MAXLINE, stdin) != NULL) {
    	Write(sockfd, buf, strlen(buf));
    	n = Read(sockfd, buf, MAXLINE);
    	if (n == 0)
    		printf("the other side has been closed.
    ");
    	else
    		Write(STDOUT_FILENO, buf, n);
    }
    

    这样企图达到通过命令行交互输入字符串, 并且可以多次跟服务器交互, 但运行下来却发现, 不work, 如下图:

    $ ./client
    // 第一次输入, 正常返回结果 
    haha1
    HAHA1 
    // 第二次输入, 无法正常返回结果
    haha2
    the other side has been closed.
    // 第三次输入, 程序自动退出
    haha3
    $
    

    原因是, 看看server.c里面针对每个连接的处理, 是应答一次之后就把连接关闭了, 所以发生了如上现象, 那么具体发生了什么呢:

    1. 在第二次输入的时候, client再次调用该句柄执行write操作, 但是write操作只是把数据写入TCP发送缓冲区就算完事儿, 所以能成功返回不会出错。而server收到该请求之后发现连接已经被关闭, 所以会返回一个RST段, client收到RST段后无法立刻通知应用层, 只是把这个状态保存在TCP协议层。
    2. 在第三次输入的时候, client再次调用循环给server写数据, 这个时候TCP协议层已经处于RST状态, 知道了这个socket连接的对方已经关闭掉了连接, 所以会发出一个SIGPIPE信号给应用层, 而SIGPIPE信号默认是终止程序, 所以看到上面的现象

    在真实线上服务, 因为一些网络异常可能会出现SIGPIPE的信号, 所以我们一般都会在服务端/客户端的程序里加上:

    signal (SIGPIPE, SIG_IGN);
    

    来避免被这种异常误杀了程序。

    那么, 我们如何才能客户端可以跟服务器端多次交互呢, 一种解决方案如下, 在服务端的处理请求的时候也加一下死循环:

    while(1) {
        ...
        accept();
        while(1) {
            n = read();
            if(n == 0) {
                break;
            }
            ...
            write();
        }
        close();
    }
    

    但是这样的修改会导致, 服务器只能串行处理每个请求, 在上一个客户端进程未终止之前, 另外一个客户端的请求服务器是不能处理的。

    那么要达到多个客户端并发处理请求的话, 一种可行的办法是每次请求来了就fork一个进程出来处理这个请求相关的逻辑, 但是这样耗费太大, 于是早些年, 先辈们提出了用select这种系统调用来解决这个问题。

    select的原理是同时监听多个阻塞的fd(网络/文件都可), 哪个有数据到达了就处理哪个, 这样就不用fork和多进程也能搞定了。

    其伪代码大概如下:

    listen_fd = socket();
    bind();
    listen();
    
    // select需要用到的句柄集合
    fd_set all_set;
    // 将listen_fd加进该集合
    FD_SET(listen_fd, &all_set);
    
    while(1) {
        // 核心系统调用, 第一个参数是需要监听的所有系统句柄中最大整数值再+1
        // 第二个参数是要监听读事件的set
        // 第三个参数是要监听写事件的set
        // 第四个参数是要监听错误事件的set
        // 第五个参数是超时事件, 如果是NULL, 则一直要阻塞到发生事件, 如果是0, 则变成非阻塞函数, 不管是否有变化都立即返回
        select(maxfd+1, &all_set, NULL, NULL, NULL);
        // 判断该socket是否事件发生
        if(FD_ISSET(listen_fd, &all_set)) {
            // 有新请求到来
            conn_fd = accept(...); 
            // 将请求连接也加到all_set当中
            FD_SET(conn_fd, &all_set);
            // 代码省略, 因为select无法返回有事件触发的具体fd, 所以需要将conn_fd加入另外一个数组, 
            // 假设该数组名为all_clients
            ...
        }
        for(i=0; i<max_clients_num; i++) {
            if(FD_ISSET(all_clients[i], &all_set)) {
                read(...)
                write(...)
            }
        }
    }
    

    这样就能做到多个客户端同时跟该服务器打交道, 也能同时得到响应了。

    虽然select能满足要求了, 但是先辈们仍然觉得其效率不高, 主要有如下几个原因:

    1. 每次调用select函数, 就得把装有所有fd的fdset都得从用户态传入内核态, 如果fd较多的时候, 开销会很大
    2. 每次调用select的时候, 都需要遍历一遍fdset的所有句柄, 这个开销在fd较多的时候也很大
    3. select支持的文件描述符太小了, 最多只能有1024

    于是内核发明了epoll来取代select, 解决如上几个问题, epoll提供了如下几个接口:

    1. epoll_create: 创建epoll句柄
    2. epoll_ctl: 将要监听的fd加入epoll
    3. epoll_wait: 查看epoll中监听的fd的事件

    那么他是如何解决如上几个问题的呢

    1. 因为用户是每次调用epoll_ctl将句柄加入epoll, 这样在内核态自身就保存有所有fd句柄信息了, 不用来回从用户态到内核态了
    2. epoll内部采用了回调机制, 每次有新事件来的时候就触发对应回调函数, 将句柄加入就绪队列, 这样其实每次epoll_wait就是从就绪队列里读句柄就好
    3. epoll没有这个限制, 他支持的FD上限就是最大可以打开文件的数目

    用epoll来实现服务器端的伪代码大概如下:

    listen_fd = socket();
    bind();
    listen();
    
    // 创建epoll句柄, 告诉内核这个epoll句柄要监听句柄数量
    epfd=epoll_create(256);
    // epoll需要用到的结构
    epoll_event ev,events[20];
    
    // 设置要加入epoll要监听的事件的信息
    ev.data.fd=listenfd;
    ev.events=EPOLLIN|EPOLLET;
    
    // 将主要的listen_fd加入epoll当中
    // 第一个参数是epoll句柄
    // 第二个参数是控制指令, 包括增删更新等
    // 第三个和第四个参数是要加入epoll监听的句柄信息
    epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
    
    while(1) {
        // 第一个参数是epoll句柄
        // 第二个参数是放有事件的句柄信息
        // 第三个参数是每次能处理的事件
        // 第四个参数是类似select的超时, -1代表阻塞, 0代表非阻塞
        int nfds = epoll_wait(epfd,events,20,-1);
        for(int i=0; i<nfds; ++i) {
            if(events[i].data.fd == listenfd) {
                conn_fd = accept(...);
                epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev);
            } else(events[i].events & EPOLLIN) {
                conn_fd = events[i].data.fd;
                read(...)
                write(...)
            }
        }
    }
    

    epoll核心的控制核心就在epoll_event.events这个数据结构上, 该字段支持如下值:

    1. EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭)

    2. EPOLLOUT:表示对应的文件描述符可以写

    3. EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)

    4. EPOLLERR:表示对应的文件描述符发生错误

    5. EPOLLHUP:表示对应的文件描述符被挂断

    6. EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的

      这里单独说明一下ET模式和LT模式, 默认是LT模式。ET模式就是epoll_wait读到该句柄之后, 应用程序必须立即处理该事件, 即触发后面的读取或者写入操作, 如果不处理的话, 那么下次调用epoll_wait的时候将不会返回该句柄。LT则反之, 如果应用层不处理, 下次依然会告诉应用层。

    7. EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里

    在现在实际的线上服务中, 一般都是用的epoll来进行连接管理和事件监听。

    但是如上的实例代码中, server端始终都是只有一个主进程在处理客户端的请求, 也就是说服务器处理是串行的, 即使并行请求, 在上一个请求处理完毕之前, 下一个请求是得不到响应的。

    所以一般服务器都会采用多线程来处理, 多线程比如上请求会复杂一些, 一般会有一个主线程(监听线程), 多个工作线程。监听线程和工作线程之间通过一个本地队列来同步信息。

    当监听线程发现有新的读请求到了之后, 就把该请求放到本地队列中, 多个工作线程就死循环check本地队列, 如果发现本地队列有新请求, 就从里面读取句柄并处理。本地队列处理读取和写入的时候, 需要考虑线程安全的问题。

    参考

    1. Linux C编程一站式学习. http://docs.linuxtone.org/ebooks/C&CPP/c/index.html
  • 相关阅读:
    MySQL中having与where
    HTML中meta标签的作用与使用
    PHP加密与解密
    mongodb----基础描述及安装
    nosql----redis性能优化
    python----案例一:爬取猫眼电影排行榜数据
    python----yield(generator)生成器
    linux下查看cpu个数的方法
    awk----利用循环统计个数
    shell----重启tomcat问题
  • 原文地址:https://www.cnblogs.com/xuanku/p/server_socket.html
Copyright © 2011-2022 走看看