Accept + read/write ,一次服务一个客户,迭代TCP服务器
总是在处理完某个客户的请求之后才转向下一个客户。不是并发服务器。
本文利用c++在Linux编写了一个echo程序,实现回显服务,把服务端将收到的数据发回客户端,主要用到的技术有Reactor 模式,socket,非阻塞IO,进程,迭代服务器编程等知识。
1. echo服务器的基本功能
将客户端发送给服务器的内容进行 ROT13加密 之后发回客户端。
2. 服务端IO模型
采用IO复用 + 非阻塞IO模型(Reactor 模式),其中IO复用采用的是Linux下的epoll机制,下面首先介绍Linux下的5中IO模型。
IO复用的是线程,不是IO连接
2.1 IO模型
- 阻塞式IO模型
调用进程会阻塞直到IO操作完成 - 非阻塞式IO模型
调用进程在内核数据未准备好时,采取轮询的方式,进程不会投入睡眠。 - IO复用模型
客户进程调用select、poll或者epoll系统调用,如果内核数据没有准备好,会阻塞在这三个系统调用上,而不会阻塞客户进程。 - 信号驱动IO模型
内核在数据准备好时,发送SIGIO通知调用进程,数据未准备好时,调用进程不会阻塞。 - 异步IO模型
调用进程告知内核启动某项工作,并让内核在整个操作(包括将数据从内核复制到用户缓冲区)完成后通知调用进程。
综上所述,阻塞和非阻塞的区别是:调用进程是否立刻返回;同步和异步的区别是:将数据从内核空间复制到用户空间时,调用进程是否阻塞。
2.2 为什么IO复用搭配非阻塞IO?
这里select手册给出了答案
Under Linux, select() may report a socket file descriptor as “ready for reading”, while nevertheless a subsequent read blocks. This could for example happen when data has arrived but upon examination has wrong checksum and is discarded. There may be other circumstances in which a file descriptor is spuriously reported as ready. Thus it may be safer to use O_NONBLOCK on sockets that should not block.
这里的意思就是说,如果某个socket接收缓冲区有新数据分节到达,然后select报告这个socket描述符可读,但随后,协议栈检查到这个新分节检验和错误,然后丢弃这个分节,这时候调用read则无数据可读,如果socket没有被设置nonblocking,此read将阻塞当前线程。如果socket被设置为非阻塞的话,没有数据可读时,就会返回一个错误。
2.3 epoll触发机制的区别
2.3.1 水平触发和边沿触发
select只支持水平触发,epoll支持水平触发和边沿触发两种模式,默认模式为水平触发。那么水平触发和边沿触发的区别是什么呢?
水平触发:只要满足条件,就触发事件(数据未读完,内核会一直通知你)。
边沿触发:每当状态变化时,触发事件。
边沿触发存在一个问题:read一个文件描述符时,一定要将buffer中的数据全部读完,也就是反复用read(),直至其遇到EAGAIN或者read返回值为0 为止;如果没有读完,则系统互认为该文件描述符状态没有变化,就不会再通知此文件描述符了,此时这个文件描述符就像dead一样。
2.3.1select、poll、epoll
- select存在的问题
(1)其FD_SET数量有限;
(2)对socket采用轮询的方式,每次都要遍历FD_SET,效率低;
(3)需要维护一个存放大量fd 结构的空间,在内核态和用户态之间传递开销大。 - poll存在的问题
(1)大量的FD_SET数组在用户态和内核态之间传递,开销大;
(2)边沿触发存在的问题,如上所述。 - epoll高效的原因 -------分清了操作频繁和不频繁的操作
(1)新建epoll_create描述符;
(2)epoll_ctrl(添加或者删除所有待监控的连接)
(3)返回活跃连接epoll_wait
epoll三要素:红黑树、mmap和链表。
epoll内部实现是通过内核与用户空间mmap一块内存实现的,mmap将用户空间的一块地址和内核空间额度一块地址映射到相同的一块物理内存地址,减少了内核态和用户态之间的数据交换。
红黑树将存储epoll所监听的套接字,添加和删除socket时性能好。
2.4 可能遇到的问题以及解决方案
2.4.1 服务端问题
- 假定一个情形,启动了一个监听服务器,连接请求到达,派生一个子进程来处理这个客户请求,但是由于某种原因,监听服务器终止了(子进程继续为鲜有连接上的客户提供服务),重启监听服务器。
在默认情况下,当监听服务器重启时,通过调用socket、bind、listen重新启动时,由于它试图捆绑一个现有连接(之前派生的子进程处理着的连接)上的端口,从而bind调用会失败。
解决办法: 在socket 和bind 两个调用之间设置了SO_REUSEADDR套接字选项,则此时bind就会调用成功。 (所有TCP服务器都应该指定本套接字选项,以允许服务器在此情形下被重新启动)
- 假定一个情形,由于服务器并发连接数较大,导致有新连接到达时,accept()返回EMFILE错误时,应该怎么办?
这种情形意味着本进程的文件描述符已经到达了上限,无法为新连接创建socket文件描述符,但是由于此”连接“ 并没有获得文件描述符,我们就无法close(),程序继续运行,epoll_wait()会立刻返回,因为新连接待处理,listening fd 还是可读的,这样互导致服务端程序陷入busy loop,影响其他连接的正常运行。
解决办法: 由于文件描述符是hard limit,我们可以自己设置一个稍低一点的soft limit ,如果连接数超过了这个soft limit, 就主动关闭新连接。
2.4.1 客户端
使用非阻塞IO时,通常将应用程序任务划分到多个进程(使用fork)或多个线程,这里我使用的是将当前进程划分成两个子进程,其中子进程用来将来自服务器的消息复制到标准输出,父进程将来自客户端标准输入的消息复制给服务器,如图所示。父子进程共享同一个套接字:父进程往套接字里面写,子进程从套接字里读,两个文件描述符在引用同一个套接字
######## echo.h ############
#ifndef _ECHO_
#define _ECHO_
#include <sys/types.h> //常用数据类型定义头文件
#include <sys/socket.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <error.h>
#include <arpa/inet.h> //地址转换
#include <unistd.h> //提供对操作系统应用接口访问功能的头文件 fork 等、、、
#include <stdlib.h> //常用的系统函数,free、、、
#include <stdio.h>
#include <string>
#include <cstring>
using namespace std;
#define MAX_EVENT_NUMBER 1024
#define BUF_SIZE 300
#define EPOLL_SIZE 100 //epoll最大监听数
#define PORT "2019" //atoi(PORT),将字符串变为整形
#define SERVERIP "127.0.0.1"
int setNonblocking(int sockfd){
int flags = fcntl(sockfd,F_GETFL);//获取旧的文件标志
int newflags = flags | O_NONBLOCK;
int n;
if((n = fcntl(sockfd,newflags)