一、概述
本文是一篇关于TCP网络服务端的常用设计模式的笔记,方便自己和已有一定的网络及线程基础知识的人查阅。
二、方式介绍
1. 同步阻塞网络模式:
基本为以下函数的顺序执行:
int socket(int domain, int type, int protocol);
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
int listen(int sockfd, int backlog);
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);ssize_t read(int fd, void *buf, size_t count);doSomething(const char *buf);ssize_t write(int fd, const void *buf, size_t count);int close(int fd);int close(int sockfd);上面从accept到close(fd)的步骤可以放入循环体中进行,以实现多客户端处理。这种方式的缺点是显然的:1)accept可能被阻塞,程序不能执行其他任何操作。 2)read可能被阻塞,使得另外的客户端来尝试连接时,都会阻塞直至失败。 3)同上,write也可能会出现阻塞。2.I/O多路复用以上的问题,源自于调用都是同步阻塞的调用,为了克服这个问题,于是操作系统帮我们设计了编写非阻塞网络服务的工具select/poll.假设我们使用select的方式实现,代码大概如下:int socket(int domain, int type, int protocol);int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);int listen(int sockfd, int backlog);fd_set readset; FD_ZERO(&readset); FD_SET(sockfd, &readset); for(;;) { int select(int nfds, fd_set* readset, fd_set* writeset, fe_set* exceptset, struct timeval* timeout); if(FD_ISSET(sockfd, &readset) { int clientfd = accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); clientArray[].apend(clientfd); FD_SET(clientfd, &clientfd); } foreach(int fd, clientArray) {ssize_t read(int fd, void *buf, size_t count);doSomething(const char *buf);ssize_t write(int fd, const void *buf, size_t count);}}foreach(int fd, clientArray){int close(int fd);}int close(int sockfd);采用了select的实现后,程序不会因为accept/read/write而阻塞了,因为只有有新的连接,新的数据可读或者可写时,程序才会去调用这几个函数(要完成实现这一步,上面的write需要作些修改)。select/poll是怎么实现的呢?其实在你调用select的时候,它会把你给它的所有fd_set全部拷贝到内核层里去,然后不断地循环检查这些fd中,是否有状态的变化直至超时,然后将所有有变化的fd_set返回。可见,相对应用层而言,是轻松了,但内核却忙死了,select的缺点如下:1)单个进程能够监视的文件描述符的数量存在最大限制,通常是1024,当然可以更改数量,但由于select采用轮询的方式扫描文件描述符,文件描述符数量越多,性能越差;(在linux内核头文件中,有这样的定义:#define __FD_SETSIZE 1024) 2)内核、用户空间内存拷贝问题,select需要不断地复制大量的句柄数据结构,产生巨大的开销; 3)select返回的是含有整个句柄的数组,应用程序需要遍历整个数组才能发现哪些句柄发生了事件; 4)select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行IO操作,那么之后每次select调用还是会将这些文件描述符通知进程。poll做了些改进,使用链表来保存文件描述符,因此第一个问题不存在,但后面三个问题是一样的。为了解决这个问题,linux再进一步,设计了一个不用反复拷贝文件描述符的回调式接口——epoll,分为以下三个步骤:1)int epoll_create(int size) ;打开一个虚拟的文件(linux下,一切皆文件嘛);2)int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event) ;将要监控的fd加入(op也可以为修改、删除等)刚打开的虚拟文件epfd中(其实是一个等待链表),告诉内核你要处理这些event。相应的,内核会向相应的IO驱动注册回调。3)int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout) ;然后你就等,直到fd代表的IO驱动收到新事件,回调内核的注册函数,将事件返回给你,或者超时。与select和poll不同的是,当有新的变化时,驱动调用这个注册函数后,会使得等待链表中的fd移动到另一个就绪链表中去,唤醒等待线程后,直接将所有的就绪链表数据拷贝给这个线程;而前两者,只是在链表里将对应的fd做个标志,使得被唤醒的线程还得一个个地找出已就绪的fd,当fd很多时,这显然是个比较耗时的操作。现在你只要不断的调用epoll_wait就好了,硬件一接收到新东西就会告诉应用层的你。你不用频繁的告诉内核你要监听啥事件,内核也不用拼命的帮你查询是否有新的事件,你甚至可以设为边沿触发(ET)方式,让内核对于变化的fd,只拷贝一次事件给你,直到你处理完了该fd的所有事件。大概的代码如下:int socket(int domain, int type, int protocol);int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);int listen(int sockfd, int backlog);int epoll_create(int size) ;struct epoll_event ev; ev.data.fd=sockfd; ev.events=EPOLLIN|EPOLLET;int epoll_ctl(int epfd, int op, int fd, struct epoll_event *ev);for(;;)
{
int nfds = epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
for(int i = 0; i < nfds; ++i)
{
if(events[n].data.fd == sockfd)
{
int clientfd = accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *ev); //将clientfd加入监听事件中
}
else
{
ssize_t read(int fd, void *buf, size_t count);
doSomething(const char *buf);ssize_t write(int fd, const void *buf, size_t count);}
}
foreach(int fd, clientArray){int close(int fd);}int close(int sockfd);此时,我们可以用很多可以用的开源库了,比如libevent。3.多进程与多线程事情发展到这一步,好像算是完美了。但再回头仔细看看,万一在doSomething(const char *buf)里面阻塞了呢?于是,最终多进程、多线程不得不上场了。首先,必须明确的是,多进程与多线程是一把有时候不得不用的双刃剑,能给我们的代码带来效率的同时,也会增加代码的复杂库,这个复杂库包括编码和调试。进程相对于线程而言,内核上实现上多了一些需要维护的数据结构,其他大同小异,因此我们只讨论多线程的情况。一般多线程的使用模型有几种:1)按需生成,即是在doSomething(const char *buf)函数里面,产生一个处理线程,并启动之;2)线程池,即是在上面的for(;;)循环之前,先产生一堆线程保存到一个数据结构中,然后在doSomething(const char *buf)函数里面从该数据结构中申请一个线程用来处理接收到的数据;显然,2)比1)可以减少系统不断产生和释放线程的开销。2.1)Leader Follower线程的引入,必然的带来数据同步的问题,比如我们假设上面的epoll例程为主线程,那么doSomething(const char *buf)函数里面使用子线程来处理数据的时候,主线程需要将数据通过锁+队列、共享内存、列表等等的方式同步告诉给子线程,这会增加开销,甚至可能导致我们前面辛辛苦苦设计的模型节省下来的时间全部消耗掉,于是又有人发明了Leader Follower方式。该方式在线程池的基础上,作了些改进。它将线程池中的线程规定为,一个时间,只有一个线程作为Leader进行epoll_wait操作,当这个Leader得到返回的事件后,它不着急于去处理这个事件,而是先将自己变成Follower,接着将线程池里的另外一个Follower线程变为Leader,使用其有机会进行epoll_wait操作(注意epoll_ctrl中设置EPOLLONESHOT),最后才去处理这个事件。这样,将接收和处理事件放在同一个线程里,就不存在上面的数据同步消耗了。典型的C++库有SPServer.2.2) 锁+多队列当客户端的数据包是可以无序处理的时候,上述的处理是不错,但假如数据必须有序处理时,问题就来了,因为线程的调试我们是无法预知的,可能后收到数据的线程先被线程调用的情况也是有可能的,这时候,这设计就不能使用了,可考虑在线程池的基础上使用锁+多队列的。即是我们为每一个线程规定使用同一个消息队列,同时同一个fd的事件也使用同一个消息队列来保存,以确保单一个fd产生的所有事件一直被同一个线程进行处理,直到客户端断开连接。多线程本身就包含有很多的设计模式,后面有机会再写。
时间: 2024-10-03 20:16:29