C++实战——多人会话聊天室(二)


作者:littlewhite

前面已经讲过一次多人会话聊天室的实现C++实战——多人会话聊天室(一),只不过上一篇是用最简单的方式,服务端每接收一个连接就起一个线程,而且是阻塞模式的,也就是说服务端每次调用accept函数时会一直等待有客户端连接上才会返回。今天介绍一种基于epoll模型的非阻塞方式的实现。

阻塞与非阻塞

顾名思义,阻塞就是当你调用一个函数后它会一直等在那里,知道某个信号叫醒它,最典型的例子就是read之类的函数,当你调用时它会等待标准输入,直到你在屏幕上输完敲下回车,它才会继续执行。Linux默认IO都是阻塞模型的
非阻塞就是当你调用函数之后它会立马返回,同样还是拿read举例,它不会阻塞在屏幕上等待你输入,而是立马返回,如果返回错误,那就代表没有数据可读。下面的例子可以大致说明一下差别

#include <unistd.h>
#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>

int main(int argc, char *argv[])
{
    int res;
    res = fcntl(0, F_GETFL);
    if (-1 == res)
    {
        perror("fcntl error!");
        exit(1);
    }
#ifdef NONBLOCK
    res |= O_NONBLOCK;
    if (fcntl(0, F_SETFL, res) == -1)
    {
      perror("error");
      exit(1);
    }
#endif
    char buf[100];
    int n = 0;
    n = read(0, buf, 100);
    if (-1 == n)
    {
      perror("read error");
      exit(1);
    }
    else
    {
      printf("read %d characters\n", n);
    }
    return 0;
}

代码的意思很好理解,我们从标准输入读取数据,并打印出读取了多少字节,但是我们做了个测试,当定义了宏NONBLOCK后,我们会将标准输入句柄改变成非阻塞的,宏可以通过编译时的-D参数指定,我们分别按如下指令编译,假设文件名为test.cpp

g++ test.cpp -o test_block
g++ test.cpp -D NONBLOCK -o test_nonblock

然后我们运行./test_block,程序会阻塞在屏幕上等待输入,输入hello world并回车,程序运行结束
但是当我们运行./test_nonblock时,程序报错read error: Resource temporarily unavailable,这是因为此时的标准输入是非阻塞模式,当调用read后它会立马返回,而此时并没有数据可读取,就会返回错误,但是我们按如下方式就可运行成功

echo "hello world" | ./test_nonblock

因为在read调用之前,管道里已经有了数据,所以它会去读取管道里的数据而不会出错。

epoll简介

epoll是什么,根据维基百科的说法

epoll is a Linux kernel system call. It is meant to replace the older POSIX select and poll system calls

多么霸气的介绍,我的出现就是为了代替那些不好用的东西,没有足够的底气也不会这么说

epoll的接口
epoll只有三个接口函数,如下

int epoll_create1(int flags);

创建epoll实例

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

对epoll实例(epfd)管理的文件句柄(fd)进行操作,具体操作由op给出(ADD, MODIFY, DELELT)

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

等待epoll实例(epfd)的事件,这个事件可以是新的连接,也可以是读事件和写时间

好的设计总是简单易用,epoll的设计充分说明了这一点。下面再介绍epoll里的两个重要数据结构

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_event中的events代表的是epoll的事件类型,就是前面提到的读事件和写事件,分别为EPOLLIN和EPOLLOUT,当然还有其它的,本例只用到这两个。epoll_data中的fd是这个事件关联的文件句柄

ps:以上接口和数据结构均可在Linux下用man查看,比如man epoll_ctl你就会看到接口的说明和涉及到的数据结构。在Linux下,有问题找男人!

基于epoll的服务器实现


有了上面epoll的基础,我们再来看看服务器的实现。和C++实战——多人会话聊天室(一)不同的是,在(一)中我们需要自己管理连接,对每个连接安排一个线程去处理,这也是服务器里的核心逻辑,有了epoll之后,这一步可以变得异常简单,两个版本的主要区别只在于run函数的实现,我们来看看用了epoll后的run函数是什么样的

int ChatServer::run()
{
    if (initSock() != 0)
    {
        fprintf(stderr, "initSock failed!\n");
        return 1;
    }
    int connfd = 0;
    int ret = 0;
    int maxi = 0;
    int nfds = 0;
    int i = 0, n = 0;
    char line[MAX_LINE_LEN] = "";
    string user_name;
    for(;;)
    {
        nfds = epoll_wait(epfd, events, 20, 500);
        for(i = 0; i < nfds; ++i)
        {
            DEBUG("now nfds[%d]\n", nfds);
            if(events[i].data.fd == listenfd)
            {
                if (eventAccept() != 0)
                {
                    continue;
                }
            }
            else if(events[i].events & EPOLLIN)
            {
                if (eventRecv(line, events[i]) != 0)
                {
                    continue;
                }
            }
            else if(events[i].events & EPOLLOUT)
            {
                if (eventSend(line, events[i]) != 0)
                {
                    continue;
                }
            }
        }
    }
    fprintf(stderr, "run finished!\n");
    return 0;
}

没错,你看到的是一个完成的函数实现,除去前面一些变量定义和初始化的操作,核心的部分就是一个for循环,不管系统多么复杂,只要使用epoll,总逃不脱这个for循环,下面我们简单说一下这个for循环的逻辑

  1. 首先通过epoll_wait得到需要处理的事件集合,注意这里可以同时接收到多个事件,比如一个客户端向服务器连接的同时有个客户端在向服务端发送数据,这样你就会收到两个事件
  2. 对这些事件挨个处理,判断事件类型,分别有三种类型:新客户端连接、接受数据、发送数据,其它还有一些错误异常什么的这里没有考虑
  3. 对每个事件类型,交个相应的函数去处理,当然,这里的eventAccept之类的函数是自己实现的,具体实现取决于你的服务器端的逻辑

到这里服务端的主体逻辑就完了,是不是觉得简单的令人发指了。但是细心的朋友可能发现,这里好像还没有使用epoll_ctl接口,这个接口是在各个事件的处理函数里调用的,这也是epoll模型里重要的一步,我们以eventSend为例来说明

int ChatServer::eventSend(char *line, struct epoll_event &event)
{
    char cmd[MAX_LINE_LEN] = "";
    char cmd_arg[MAX_LINE_LEN] = "";
    char ret_buf[MAX_LINE_LEN + 100] = "";
    int connfd = 0;
    string user_name;
    DEBUG("now in send\n");
    connfd = event.data.fd;
    analyse_cmd(line, cmd, cmd_arg, isLogged(connfd));
    DEBUG("cmd[%s] arg[%s]\n", cmd, cmd_arg);
    std::map<std::string, p_func>::iterator it = m_func.find(cmd);
    if (it != m_func.end())
    {
        (this->*(it->second))(cmd_arg, isLogged(connfd), connfd, this, user_name);
    }
    else
    {
        snprintf(ret_buf, MAX_LINE_LEN, "cmd error!%c%c", 30, 0);
        write(connfd, ret_buf, strlen(ret_buf));
    }
    ev.data.fd = connfd;
    ev.events = EPOLLIN | EPOLLET;
    epoll_ctl(epfd, EPOLL_CTL_MOD, connfd, &ev);
    return 0;
}

这里的line是在读事件中读到的内容,我这里把所有读写都共享了同一个buffer,这样可能会导致错误,比如处理了连续两次读事件,那么line就会存储两次读到的内容,再将line交给写事件去处理就需要做些复杂且不优雅的操作。理论上来说应该用一个消息队列来实现的,但考虑到实现的复杂度,我们暂且用这种有缺陷的方式来做,整体的网络通信的处理逻辑是没问题的。

event的数据结构见前面的介绍,从这里我们可以拿到事件对应的文件句柄,在写事件中,我们就需要往这个文件句柄发送数据,调用write函数即可完成,需要注意的是,在处理完之后,我们需要将epoll里的这个事件修改为读事件,这时候就需要调用epoll_ctl来完成了,相应的,在eventRecv函数中,我们需要在处理完后将事件修改为写事件,而在eventAccept中,则是新增一个事件,类型为EPOLLIN,因为新建一个连接后服务端会等到客户端发送消息。

服务端的整体逻辑这样就介绍完了,其它消息处理的一些细节都和(一)中完全一样,客户端也没有任何改变。项目的完整代码在https://github.com/handy1989/chatserver/tree/version1.0.2,对比(一)的代码会发现,采用epoll模型后逻辑更清晰,性能和稳定性肯定也是比自己维护连接要好很多。

总结

工欲善其事,必先利其器。实战(一)实现了简单的网络通信,并且完全自己管理所有事情,其实网络通信领域已经是非常成熟的,有很多优秀的模型可以供我们使用,在epoll之前还有poll和select,但在epoll出现之后,我们当然是选择epoll.实战一和二都是将重点放在网络通信模型上,对于消息的处理并没有采用严谨的方式,在完善的系统中,应该用消息队列来专门管理消息,而不是像我这样只用一个字符串就马虎了事,后续如果有时间,我再来做一个麻雀虽小五脏俱全的聊天室吧

发表评论

电子邮件地址不会被公开。 必填项已用*标注