用C++实现的高性能WEB服务器,经过webbenchh压力测试可以实现上万的QPS 项目地址:https://github.com/Aged-cat/WebServer 功能 利用IO复用技术Epoll与线程池实现多线程的Reactor高并发模型; 利用正则与状态机解析HTTP请求报文,实现处理静态资源的请求; 利用标准库容器封装char,实现自动增长的缓冲区; 基于堆结构实现的定时器,关闭超时的非活动连接; 改进了线程池的实现,QPS提升了45%+; 项目详解 WebServer项目——buffer详解 WebServer项目——epoller详解 WebServer项目——timer详解 WebServer项目——threadpool详解 WebServer项目——HTTPconnection详解 WebServer项目——HTTPrequest详解 WebServer项目——HTTPresponse详解 WebServer项目——webserver详解 环境要求 Linux C++11 项目启动 mkdir bin make ./bin/myserver 压力测试 ./webbench-1.5/webbench -c 100 -t 10 http://ip:port/ ./webbench-1.5/webbench -c 1000 -t 10 http://ip:port/ ./webbench-1.5/webbench -c 5000 -t 10 http://ip:port/ ./webbench-1.5/webbench -c 10000 -t 10 http://ip:port/ 测试环境: Ubuntu:20 cpu:i7-4790 内存:16G 性能表现 与markparticle的C++服务器做一个比较(表格中的为QPS的值): 10 100 1000 10000 old 8929 9126 9209 155 new 11478 13578 13375 106 性能提升了45% 致谢 @markparticle

2021年6月6日 1Comments 656Browse 5Like agedcat_xuanzai Read more

webserver介绍 这个webserver类是对整个web服务器的抽象,基于HTTPconnection类、timer类、epoller类、threadpool类实现一个完整的高性能web服务器的所有功能。 需要满足的功能有: 初始化服务器,为HTTP的连接做好准备; 处理每一个HTTP连接; 用定时器给每一个HTTP连接定时,并且处理掉过期的连接; 运用IO多路复用技术提升IO性能; 运用线程池技术提升服务器性能; webserver的逻辑 首先是进行服务器的初始化,进行各种参数设置。其中包括了事件模式的初始化、socket连接的建立过程,主要用到了以下两个函数: bool initSocket_(); void initEventMode_(int trigMode); 在初始化socket的过程中,将listenFd_描述符也加入epoll进行监视。这样的话,当监听的listenFd(socketFd)有新连接的时候,就会发来一个可读信号。同时,也将监听socket的行为(是否有新的连接)和监听每一个HTTP连接的行为(已经建立的连接有无IO请求)统一起来了,都抽象为了读写两个操作。所以我们就可以每一次直接处理所有的epoll就绪事件,在过程中对有新连接请求的情况单独分出来处理就可以了。 接下来开始处理HTTP连接。 最开始当然先清理过期的连接,并且得到下一次处理的时间。这个首先就需要在这个类中有一个TimerManager类型指针或者对象,用它调用getNextHandle()函数: timeMS=timer_->getNextHandle(); 然后得到所有已经就绪的事件,用一个循环处理所有的epoll就绪事件。在过程中需要分两种类型:收到新的HTTP请求和其他的读写请求。 收到新的HTTP请求的情况 在fd==listenFd_的时候,也就是收到新的HTTP请求的时候,需要调用函数: void handleListen_(); 这个函数中会得到新的描述符,然后需要将新的描述符和新的描述符对应的连接记录下来,调用下述函数即可: void addClientConnection(int fd, sockaddr_in addr); 已经建立连接的HTTP发来IO请求的情况 这种情况下,必然需要提供读写的处理,用下述两个函数完成: void handleWrite_(HTTPconnection* client); void handleRead_(HTTPconnection* client); 但是为了提高性能,使用了线程池,所以这两个函数就是将读写的底层实现函数加入到线程池,两个读写的底层实现函数为: void onRead_(HTTPconnection* client); void onWrite_(HTTPconnection* client); epoll使用的是边缘触发ET,所以在读结束的时候根据需要改变epoll的事件。 边缘触发 1. 对于读操作 (1)当缓冲区由不可读变为可读的时候,即缓冲区由空变为不空的时候。 (2)当有新数据到达时,即缓冲区中的待读数据变多的时候。 (3)当缓冲区有数据可读,且应用进程对相应的描述符进行EPOLL_CTL_MOD 修改EPOLLIN事件时。 2. 对于写操作 (1)当缓冲区由不可写变为可写时。 (2)当有旧数据被发送走,即缓冲区中的内容变少的时候。 (3)当缓冲区有空间可写,且应用进程对相应的描述符进行EPOLL_CTL_MOD 修改EPOLLOUT事件时。 用函数: void onProcess_(HTTPconnection* client); 来完成。 当一切都完成的时候,还需要一个关闭服务器的函数: void closeConn_(HTTPconnection* client); WebServer的实现 class WebServer { public: WebServer(int port,int trigMode,int timeoutMS,bool optLinger,int threadNum); ~WebServer(); void Start(); //一切的开始 private: //对服务端的socket进行设置,最后可以得到listenFd bool initSocket_(); void initEventMode_(int trigMode); void addClientConnection(int fd, sockaddr_in addr); //添加一个HTTP连接 void closeConn_(HTTPconnection* client); //关闭一个HTTP连接 void handleListen_(); void handleWrite_(HTTPconnection* client); void handleRead_(HTTPconnection* client); void onRead_(HTTPconnection* client); void onWrite_(HTTPconnection* client); void onProcess_(HTTPconnection* client); void sendError_(int fd, const char* info); void extentTime_(HTTPconnection* client); static const int MAX_FD = 65536; static int setFdNonblock(int fd); int port_; int timeoutMS_; /* 毫秒MS,定时器的默认过期时间 */ bool isClose_; int listenFd_; bool openLinger_; char* srcDir_;//需要获取的路径 uint32_t listenEvent_; uint32_t connectionEvent_; std::unique_ptr<TimerManager>timer_; std::unique_ptr<ThreadPool> threadpool_; std::unique_ptr<Epoller> epoller_; std::unordered_map<int, HTTPconnection> users_; };

2021年6月6日 0Comments 198Browse 1Like agedcat_xuanzai Read more

定时器的介绍 为了提高Web服务器的效率,我们考虑给每一个HTTP连接加一个定时器。 定时器给每一个HTTP连接设置一个过期时间,然后我们定时清理超过过期时间的连接,会减少服务器的无效资源的耗费,提高服务器的运行效率。 我们还需要考虑一下如何管理和组织这些定时器。设置定时器的主要目的是为了清理过期连接,为了方便找到过期连接,首先考虑使用优先队列,按过期时间排序,让过期的排在前面就可以了。但是这样的话,虽然处理过期连接方便了,当时没法更新一个连接的过期时间。 最后,选择一个折中的方法。用vector容器存储定时器,然后在这之上实现堆结构,这样各个操作的代价就得到了相对均衡。 定时器的组成 定时器结点 为了实现定时器的功能,我们首先需要辨别每一个HTTP连接,每一个HTTP连接会有一个独有的描述符(socket),我们可以用这个描述符来标记这个连接,记为id。同时,我们还需要设置每一个HTTP连接的过期时间。 为了后面处理过期连接的方便,我们给每一个定时器里面放置一个回调函数,用来关闭过期连接。 为了便于定时器结点的比较,主要是后续堆结构的实现方便,我们还需要重载比较运算符。 class TimerNode{ public: int id; //用来标记定时器 TimeStamp expire; //设置过期时间 TimeoutCallBack cb; //设置一个回调函数用来方便删除定时器时将对应的HTTP连接关闭 //需要的功能可以自己设定 bool operator<(const TimerNode& t) { return expire<t.expire; } }; 定时器的管理 我们定义一个类 TimerManager来管理定时器。然后考虑一下这儿结构的构成。 实现堆结构必须的组件 首先是用来存储定时器的存储实体,用vector就可以: std::vector<TimerNode>heap_; 然后在vector的基础上实现堆结构还需要下列方法: void addTimer(int id,int timeout,const TimeoutCallBack& cb);//添加一个定时器 void del_(size_t i);//删除指定定时器 void siftup_(size_t i);//向上调整 bool siftdown_(size_t index,size_t n);//向下调整 void swapNode_(size_t i,size_t j);//交换两个结点位置 对于堆结构,主要就是往堆里添加一个结点和删除一个结点,对应的就是addTimer方法和del_方法,但是实现这两个方法同时还需要实现交换两个结点的 swapNode_方法和向上调整向下调整的方法。 提供给外界的接口 主要包括下面的方法: void addTimer(int id,int timeout,const TimeoutCallBack& cb); //处理过期的定时器 void handle_expired_event(); //下一次处理过期定时器的时间 int getNextHandle(); void update(int id,int timeout); //删除制定id节点,并且用指针触发处理函数 void work(int id); 添加定时器的方法也需要暴露给上层,还有就是最主要的处理过期连接的方法handle_expired_event,以及获取下一次处理的时间的方法int getNextHandle。还有就是在HTTP连接的处理过程中需要的对某一个连接对应定时器的过期时间做出改变所需要的update方法和处理过期时间过程中需要调用的work方法。 构造和析构函数 构造的时候初始化一下vector,析构的时候清理vector。 还可能有一些在实现这些方法的过程中提取出来的一个子模块所构成的方法。 timer总览 class TimerNode{ public: int id; //用来标记定时器 TimeStamp expire; //设置过期时间 TimeoutCallBack cb; //设置一个回调函数用来方便删除定时器时将对应的HTTP连接关闭 //需要的功能可以自己设定 bool operator<(const TimerNode& t) { return expire<t.expire; } }; class TimerManager{ public: TimerManager() {heap_.reserve(64);} ~TimerManager() {clear();} //设置定时器 void addTimer(int id,int timeout,const TimeoutCallBack& cb); //处理过期的定时器 void handle_expired_event(); //下一次处理过期定时器的时间 int getNextHandle(); void update(int id,int timeout); //删除制定id节点,并且用指针触发处理函数 void work(int id); void pop(); void clear(); private: void del_(size_t i); void siftup_(size_t i); bool siftdown_(size_t index,size_t n); void swapNode_(size_t i,size_t j); std::vector<TimerNode>heap_; std::unordered_map<int,size_t>ref_;//映射一个fd对应的定时器在heap_中的位置 };

2021年6月6日 0Comments 182Browse 0Like agedcat_xuanzai Read more

HTTPresponse简介 这个类和HTTPrequest相反,是给相应的连接生成相应报文的。HTTPrequest是解析请求行,请求头和数据体,那么HTTPresponse就是生成请求行,请求头和数据体。 HTTPresponse的组成 所需变量和自定义的数据结构 首先,我们需要一个变量code_来代表HTTP的状态。 在HTTPrequest中解析到的路径信息是相对路径,我们还需要补全,所以需要一个变量path_代表解析得到的路径,一个变量srcDir_表示根目录,除此之外,我们还需要一个哈希表提供4XX状态码到响应文件路径的映射。 我们在实现所需函数的过程中,需要知道HTTP连接是否处于KeepAlive状态,所以用一个变量isKeepAlive_表示。 由于使用了共享内存,所以也需要变量和数据结构指示相关信息: char* mmFile_; struct stat mmFileStat_; 所以,总结如下: int code_; bool isKeepAlive_; std::string path_; std::string srcDir_; char* mmFile_; struct stat mmFileStat_; static const std::unordered_map<std::string, std::string> SUFFIX_TYPE; static const std::unordered_map<int, std::string> CODE_STATUS; static const std::unordered_map<int, std::string> CODE_PATH; 其中,哈希表SUFFIX_TYPE表示后缀名到文件类型的映射关系,哈希表CODE_STATUS表示状态码到相应状态(字符串类型)的映射。 构造函数和析构函数 这个类中的构造函数和析构函数就是默认的,不需要做什么操作。虽然会有初始化的函数,但是不需要在这里初始化,因为需要初始化srcDir在这里没法获取。 生成响应报文函数 这个类的主要部分就是就是生成相应报文,也就是生成请求行,请求头和数据体,分别对应以下函数: void addStateLine_(Buffer& buffer); void addResponseHeader_(Buffer& buffer); void addResponseContent_(Buffer& buffer); 在设计的时候,对于4XX的状态码是分开考虑的,这部分由函数: void errorHTML_(); 实现。 在添加请求头的时候,我们需要得到文件类型信息,这个由函数: std::string getFileType_(); 实现。 在添加数据体的函数中,如果所请求的文件打不开,我们需要返回相应的错误信息,这个功能由函数: void errorContent(Buffer& buffer,std::string message); 实现。 最后,生成响应报文的主函数为: void makeResponse(Buffer& buffer); 暴露给外界的接口 返回状态码的函数: int code() const {return code_;} 返回文件信息的函数: char* file(); size_t fileLen() const; 其他函数 初始化函数: void init(const std::string& srcDir,std::string& path,bool isKeepAlive=false,int code=-1); 共享内存的扫尾函数: void unmapFile_(); HTTPresponse的实现 class HTTPresponse { public: HTTPresponse(); ~HTTPresponse(); void init(const std::string& srcDir,std::string& path,bool isKeepAlive=false,int code=-1); void makeResponse(Buffer& buffer); void unmapFile_(); char* file(); size_t fileLen() const; void errorContent(Buffer& buffer,std::string message); int code() const {return code_;} private: void addStateLine_(Buffer& buffer); void addResponseHeader_(Buffer& buffer); void addResponseContent_(Buffer& buffer); void errorHTML_(); std::string getFileType_(); int code_; bool isKeepAlive_; std::string path_; std::string srcDir_; char* mmFile_; struct stat mmFileStat_; static const std::unordered_map<std::string, std::string> SUFFIX_TYPE; static const std::unordered_map<int, std::string> CODE_STATUS; static const std::unordered_map<int, std::string> CODE_PATH; };

2021年6月6日 0Comments 84Browse 0Like agedcat_xuanzai Read more

HTTPrequest简介 这个类主要的功能是解析HTTP的请求信息。 HTTP的请求包括:请求行(request line)、请求头部(header)、空行 和 请求数据 四个部分组成。 抓包的request结构如下: GET /mix/76.html?name=kelvin&password=123456 HTTP/1.1 Host: www.fishbay.cn Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8 Accept-Encoding: gzip, deflate, sdch Accept-Language: zh-CN,zh;q=0.8,en;q=0.6 1.请求行 GET为请求类型,/mix/76.html?name=kelvin&password=123456为要访问的资源,HTTP/1.1是协议版本 2.请求头部 从第二行起为请求头部,Host指出请求的目的地(主机域名);User-Agent是客户端的信息,它是检测浏览器类型的重要信息,由浏览器定义,并且在每个请求中自动发送。 3.空行 请求头后面必须有一个空行 4.请求数据 请求的数据也叫请求体,可以添加任意的其它数据。这个例子的请求体为空。 由上述的简单描述可以看出来,我们的主要任务就是解析传进来的buffer里面的请求信息,也就是把各个有用信息分割开来。 HTTPrequest的组成 所需变量和自定义数据结构 首先就是各个有意义的字段变量,比如必须用字符串类型的变量存储HTTP方式、路径、版本和数据体,以及需要用两个哈希表分别存储请求头和post已经解析出来的信息。 为了访问方便,先用一个集合存储了默认的网页名称。除此之外,还需要一个用来实现自动机的state变量。 PARSE_STATE state_; std::string method_,path_,version_,body_; std::unordered_map<std::string,std::string>header_; std::unordered_map<std::string,std::string>post_; static const std::unordered_set<std::string>DEFAULT_HTML; 构造和析构函数 这个类的析构函数默认就可以了,但是构造的时候需要初始化上述的变量,这些可以用一个方法init汇总起来: void init(); 主要的解析函数 主要的HTTP请求信息的解析可以用下述的有限状态自动机实现: 主流程由函数parse完成: bool parse(Buffer& buff); //解析HTTP请求 这个函数在实现过程中,根据HTTP请求报文的格式,用"\r\n"作为分隔符将报文分割成行,然后运用自动机来进行解析。在这个过程中,需要分别实现解析请求行、请求头和数据体的函数: bool parseRequestLine_(const std::string& line);//解析请求行 void parseRequestHeader_(const std::string& line); //解析请求头 void parseDataBody_(const std::string& line); //解析数据体 在解析请求行的时候,会解析出路径信息,之后还需要对路径信息做一个处理: void parsePath_(); 在处理数据体的时候,如果格式是post,那么还需要解析post报文,用函数parsePost实现: void parsePost_(); 暴露给外界的接口 这个类最后需要向上层提供解析后的信息,所以需要各种获取HTTP信息的接口。 获取路径、HTTP方式、版本的函数如下: std::string path() const; std::string& path(); std::string method() const; std::string version() const; 以及在post方式下获取信息的接口: std::string getPost(const std::string& key) const; std::string getPost(const char* key) const; 获取HTTP连接是否KeepAlive的函数: bool isKeepAlive() const; 其他方法函数 还有一些其他的方法函数,比如转换Hex格式的函数: static int convertHex(char ch); HTTPrequest的实现 class HTTPrequest { public: enum PARSE_STATE{ REQUEST_LINE, HEADERS, BODY, FINISH, }; HTTPrequest() {init();}; ~HTTPrequest()=default; void init(); bool parse(Buffer& buff); //解析HTTP请求 //获取HTTP信息 std::string path() const; std::string& path(); std::string method() const; std::string version() const; std::string getPost(const std::string& key) const; std::string getPost(const char* key) const; bool isKeepAlive() const; private: bool parseRequestLine_(const std::string& line);//解析请求行 void parseRequestHeader_(const std::string& line); //解析请求头 void parseDataBody_(const std::string& line); //解析数据体 void parsePath_(); void parsePost_(); static int convertHex(char ch); PARSE_STATE state_; std::string method_,path_,version_,body_; std::unordered_map<std::string,std::string>header_; std::unordered_map<std::string,std::string>post_; static const std::unordered_set<std::string>DEFAULT_HTML; };

2021年6月6日 0Comments 116Browse 0Like agedcat_xuanzai Read more

HTTPconnection简介 这个类就是对一个HTTP连接的抽象,负责对一个HTTP请求的解析和回复,以及提供读写的接口。 这个读写接口的底层就是靠buffer缓冲区来实现的,这个缓冲区提供了读写的接口。但是,写借口照样用了分散写的方法实现。然后就是对从socket连接中读取的数据进行解析,以及对请求做出响应。这部分的实现主要依赖于HTTPrequest和HTTPresponse来完成。 HTTPconnection组成 其中的构造函数和析构函数略去不谈,缺省就可以。 所需变量和自定义的数据结构 对于一个HTTP连接而言,我们需要用变量fd_唯一地标记它,用isClose_表示它是否需要关闭这个连接,已备后续关闭连接的函数的判断。一个HTTP连接还需要读写数据,所以给每一个HTTP连接定义一个读缓冲区和一个写缓冲区。在解析请求和响应请求的时候,我们借助HTTPrequest和HTTPresponse完成,所以也需要各种定义一个这两种变量。 总结一下,如下所示: int fd_; //HTTP连接对应的描述符 bool isClose_; //标记是否关闭连接 Buffer readBuffer_; //读缓冲区 Buffer writeBuffer_; //写缓冲区 HTTPrequest request_; HTTPresponse response_; 还需要一些其他的变量。比如响应请求的时候需要当前目录的路径这个值的传入,我们在这里需要定义它;比如,如果我们需要统计HTTP连接的个数,也就是用户的个数,最准确的方法就是在HTTP连接初始化和关闭的时候更改数据,在上层初始化它。 static const char* srcDir; static std::atomic<int>userCount; 初始化和关闭连接的接口 这两个函数顾名思义就是用来初始化连接和关闭连接的时候使用的: void initHTTPConn(int socketFd,const sockaddr_in& addr); void closeHTTPConn(); 读写接口 这个也很容易理解,每个HTTP连接都需要读写数据,这两个就是定义的读写接口: ssize_t readBuffer(int* saveErrno); ssize_t writeBuffer(int* saveErrno); 获取HTTP连接信息的函数 这个类也需要提供一些暴露给外界的接口,用于获取当前HTTP连接的信息: const char* getIP() const;//获取IP信息 int getPort() const;//获取IP信息 int getFd() const;//获取HTTP连接的描述符,也就是唯一标志 sockaddr_in getAddr() const; int writeBytes();//获取已经写入的数据长度 bool isKeepAlive() const;//获取这个HTTP连接KeepAlive的状态 主流程 这个类的主要函数就是用于完成解析请求和响应请求的整体逻辑,这些都在主函数中实现: bool handleHTTPConn(); HTTPconnection的实现 class HTTPconnection{ public: HTTPconnection(); ~HTTPconnection(); void initHTTPConn(int socketFd,const sockaddr_in& addr); //每个连接中定义的对缓冲区的读写接口 ssize_t readBuffer(int* saveErrno); ssize_t writeBuffer(int* saveErrno); //关闭HTTP连接的接口 void closeHTTPConn(); //定义处理该HTTP连接的接口,主要分为request的解析和response的生成 bool handleHTTPConn(); //其他方法 const char* getIP() const; int getPort() const; int getFd() const; sockaddr_in getAddr() const; int writeBytes() { return iov_[1].iov_len+iov_[0].iov_len; } bool isKeepAlive() const { return request_.isKeepAlive(); } static bool isET; static const char* srcDir; static std::atomic<int>userCount; private: int fd_; //HTTP连接对应的描述符 struct sockaddr_in addr_; bool isClose_; //标记是否关闭连接 int iovCnt_; struct iovec iov_[2]; Buffer readBuffer_; //读缓冲区 Buffer writeBuffer_; //写缓冲区 HTTPrequest request_; HTTPresponse response_; };

2021年6月6日 0Comments 158Browse 0Like agedcat_xuanzai Read more

epoller的简介 web服务器需要与客户端之间发生大量的IO操作,这也是性能的瓶颈之一。在这个项目中,我们用IO多路复用技术中的epoll来尽可能地提高一下性能。 epoll区别于select和poll,不需要每次轮询整个描述符集合来查找哪个描述符对应的IO已经做好准备了,epoll采用事件驱动的方式,当有事件准备就绪后就会一次返回已经做好准备的所有描述符集合。 epoll提供的程序接口有: int epoll_create(int size); 在内核中创建epoll实例并返回一个epoll文件描述符。 在最初的实现中,调用者通过 size 参数告知内核需要监听的文件描述符数量。如果监听的文件描述符数量超过 size, 则内核会自动扩容。而现在 size 已经没有这种语义了,但是调用者调用时 size 依然必须大于 0,以保证后向兼容性。 int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); 向 epfd 对应的内核epoll 实例添加、修改或删除对 fd 上事件 event 的监听。op 可以为 EPOLL_CTL_ADD, EPOLL_CTL_MOD, EPOLL_CTL_DEL 分别对应的是添加新的事件,修改文件描述符上监听的事件类型,从实例上删除一个事件。如果 event 的 events 属性设置了 EPOLLET flag,那么监听该事件的方式是边缘触发。 int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); 当 timeout 为 0 时,epoll_wait 永远会立即返回。而 timeout 为 -1 时,epoll_wait 会一直阻塞直到任一已注册的事件变为就绪。当 timeout 为一正整数时,epoll 会阻塞直到计时 timeout 毫秒终了或已注册的事件变为就绪。因为内核调度延迟,阻塞的时间可能会略微超过 timeout 毫秒。 这个类抽象了epoll的操作,为上层的需求提供支撑。 epoller的组成 所需的变量和自定义的数据结构 我们需要用一个变量epollerFd_唯一地标记这个epoll,同时还需要一个vector来存储需要返回的准备就绪的事件。 构造函数与析构函数 析构函数默认即可,构造函数需要调用epoll_create函数来对epoll进行初始化。 对单个fd的操作 主要涉及的是将某个fd添加进epoll进行监控,将某个fd移除epoll取消监控和改变某个fd对应的事件,这些都是通过调用epoll_ctl方法实现的: //将描述符fd加入epoll监控 bool addFd(int fd,uint32_t events); //修改描述符fd对应的事件 bool modFd(int fd,uint32_t events); //将描述符fd移除epoll的监控 bool delFd(int fd); 返回准备就绪的fd集合 这部分功能已经由epoll_wait方法实现了,也只是需要在这基础上封装一下就可以了: int wait(int timewait = -1); 返回就绪fd的信息 主要就是提供对外接口,获取就绪IO的描述符和事件: //获取fd的函数 int getEventFd(size_t i) const; //获取events的函数 uint32_t getEvents(size_t i) const; 这部分主要在服务器需要处理就绪IO事件的时候需要调用。 epoller的实现 class Epoller{ public: explicit Epoller(int maxEvent=1024); ~Epoller(); //将描述符fd加入epoll监控 bool addFd(int fd,uint32_t events); //修改描述符fd对应的事件 bool modFd(int fd,uint32_t events); //将描述符fd移除epoll的监控 bool delFd(int fd); //用于返回监控的结果,成功时返回就绪的文件描述符的个数 int wait(int timewait = -1); //获取fd的函数 int getEventFd(size_t i) const; //获取events的函数 uint32_t getEvents(size_t i) const; private: int epollerFd_;//这是标志epoll的描述符 std::vector<struct epoll_event>events_; //就绪的事件 };

2021年6月6日 0Comments 148Browse 0Like agedcat_xuanzai Read more

buffer缓冲区的介绍 在这个项目中,客户端连接发来的HTTP请求以及回复给客户端所请求的资源,都需要缓冲区的存在。其实,在操作系统的内核中就有缓冲区的实现,read()/write()的调用就离不开缓冲区的支持。但是,在这里用缓冲区的实现不太方便。所以,在这个项目中实现了一个符合需要的缓冲区结构。 在C++的STL库中,vector容器其实就很适合作为缓冲区。为了能够满足我们的需要,我们以vector容器作为底层实体,在它的上面封装自己所需要的方法来实现一个自己的buffer缓冲区,满足读写的需要。 buffer缓冲区的组成 省去每一个类必有的构造和析构函数,还需要以下部分: buffer的存储实体 缓冲区的最主要需要是读写数据的存储,也就是需要一个存储的实体。自己去写太繁琐了,直接用vector来完成。也就是buffer缓冲区里面需要一个: std::vector<char>buffer_; buffer所需要的变量 由于buffer缓冲区既要作为读缓冲区,也要作为写缓冲区,所以我们既需要指示当前读到哪里了,也需要指示当前写到哪里了。所以在buffer缓冲区里面设置变量: std::atomic<std::size_t>readPos_; std::atomic<std::size_t>writePos_; 分别指示当前读写位置的下标。 buffer所需要的方法 读写接口 缓冲区最重要的就是读写接口,主要可以分为与客户端直接IO交互所需要的读写接口,以及收到客户端HTTP请求后,我们在处理过程中需要对缓冲区的读写接口。 与客户端直接IO的读写借口 ssize_t readFd(int fd,int* Errno); ssize_t writeFd(int fd,int* Errno); 这个功能直接用read()/write()、readv()/writev()函数来实现。从某个连接接受数据的时候,有可能会超过vector的容量,所以我们用readv()来分散接受来的数据。当然,我们也可以用vector的动态扩容技术,但是代价比较高,不划算。需要往某个连接发送数据的时候,不用担心这个问题,调用write()就可以了。 最后无论是读还是写,结束之后都需要更新读指针和写指针的位置。 处理HTTP连接过程中需要的读写接口 需要读buffer里面的数据,一般情况下也需要定义方法。但是在这里,我们用STL提供的对vector的方法和对string的支持就可以实现这些功能。所以,我们这部分主要需要实现向buffer缓冲区中添加数据的方法。 void append(const char* str,size_t len); void append(const std::string& str); void append(const void* data,size_t len); void append(const Buffer& buffer); 根据后续功能的需要,写了各种需要的实现。其中的具体功能可以参考具体的代码实现。 在往buffer缓冲区中添加数据也需要考虑超过容量的情况,也就是我们还需要实现这种情况下怎么动态扩容,怎么保证能够写入超过现有容量的数据,怎么分配新的空间。 也就是以下方法: void ensureWriteable(size_t len); void allocateSpace(size_t len); 更新定义变量的方法 在读写结束后,我们自然需要更新读指针和写指针的位置,也就是指示读写指针的变量的值。 void updateReadPtr(size_t len); void updateReadPtrUntilEnd(const char* end); void updateWritePtr(size_t len); void initPtr(); 上述方法,分别为读指定长度后读指针的的更新方法,将读指针移到指定位置的方法,写入指定长度后写指针的更新方法和读写指针初始化的方法。 获取信息的接口 这些都主要是为了上层的需求而实现的接口。 比如以下方法获取缓冲区中的信息: //缓存区中还可以读取的字节数 size_t writeableBytes() const; //缓存区中还可以写入的字节数 size_t readableBytes() const; //缓存区中已经读取的字节数 size_t readBytes() const; 以指针的方式获取当前读写指针: //获取当前读指针 const char* curReadPtr() const; //获取当前写指针 const char* curWritePtrConst() const; char* curWritePtr(); 以及实现过程中需要的方法: //返回指向缓冲区初始位置的指针 char* BeginPtr_(); const char* BeginPtr_() const; 其他方法 比如: //将缓冲区的数据转化为字符串 std::string AlltoStr(); buffer缓冲区的实现 class Buffer{ public: Buffer(int initBufferSize=1024); ~Buffer()=default; //缓存区中可以读取的字节数 size_t writeableBytes() const; //缓存区中可以写入的字节数 size_t readableBytes() const; //缓存区中已经读取的字节数 size_t readBytes() const; //获取当前读指针 const char* curReadPtr() const; //获取当前写指针 const char* curWritePtrConst() const; char* curWritePtr(); //更新读指针 void updateReadPtr(size_t len); void updateReadPtrUntilEnd(const char* end);//将读指针直接更新到指定位置 //更新写指针 void updateWritePtr(size_t len); //将读指针和写指针初始化 void initPtr(); //保证将数据写入缓冲区 void ensureWriteable(size_t len); //将数据写入到缓冲区 void append(const char* str,size_t len); void append(const std::string& str); void append(const void* data,size_t len); void append(const Buffer& buffer); //IO操作的读与写接口 ssize_t readFd(int fd,int* Errno); ssize_t writeFd(int fd,int* Errno); //将缓冲区的数据转化为字符串 std::string AlltoStr(); private: //返回指向缓冲区初始位置的指针 char* BeginPtr_(); const char* BeginPtr_() const; //用于缓冲区空间不够时的扩容 void allocateSpace(size_t len); std::vector<char>buffer_; //buffer的实体 std::atomic<std::size_t>readPos_;//用于指示读指针 std::atomic<std::size_t>writePos_;//用于指示写指针 };

2021年6月6日 0Comments 274Browse 0Like agedcat_xuanzai Read more

静态库与动态库 由文章C++编译过程可知,C++源文件编译的过程要经过以下四个步骤: $$ 预处理 \rightarrow 编译 \rightarrow 汇编 \rightarrow 链接 $$ 其中在编译和链接的过程中,我们常常需要与静态库和动态库打交道。 在此之前,先了解一下ELF文件格式。 ELF文件格式 编译器编译源代码后生成的文件叫做目标文件,而目标文件经过编译器链接之后得到的就是可执行文件。那么目标文件到底是什么? 目前,PC平台流行的 可执行文件格式(Executable) 主要包含如下两种,它们都是 COFF(Common File Format) 格式的变种。 Windows下的 PE(Portable Executable) Linux下的 ELF(Executable Linkable Format) 目标文件就是源代码经过编译后但未进行连接的那些中间文件(Windows的.obj和Linux的.o),它与可执行文件的格式非常相似,所以一般跟可执行文件格式一起采用同一种格式存储。在Windows下采用PE-COFF文件格式;Linux下采用ELF文件格式。 事实上,除了可执行文件外,动态链接库(DDL,Dynamic Linking Library)、静态链接库(Static Linking Library) 均采用可执行文件格式存储。它们在Window下均按照PE-COFF格式存储;Linux下均按照ELF格式存储。只是文件名后缀不同而已。 总结来说,ELF文件格式包括三种主要的类型:可执行文件、可重定向文件、共享库。 1.可执行文件(应用程序) 可执行文件包含了代码和数据,是可以直接运行的程序。 2.可重定向文件(*.o) 可重定向文件又称为目标文件,它包含了代码和数据(这些数据是和其他重定位文件和共享的object文件一起连接时使用的)。 .o文件参与程序的连接(创建一个程序)和程序的执行(运行一个程序),它提供了一个方便有效的方法来用并行的视角看待文件的内容,这些.o文件的活动可以反映出不同的需要。 Linux下,我们可以用gcc -c编译源文件时可将其编译成*.o格式。 3.共享文件(*.so) 也称为动态库文件,它包含了代码和数据(这些数据是在连接时候被连接器ld和运行时动态连接器使用的)。动态连接器可能称为ld.so.1,libc.so.1或者 ld-linux.so.1。我的CentOS6.0系统中该文件为:/lib/ld-2.12.so 那么,什么是库呢? 库是一组目标文件的包,就是一些最常用的代码编译成目标文件后打包存放。 静态库 Windows:.lib 格式 Linux:.a 格式 静态库是在汇编过程生成的,加载静态态库是在链接过程之前。静态库实际就是一些目标文件(一般以.o结尾)的集合,静态库一般以.a结尾,只用于生成可执行文件阶段。 在链接步骤中,链接器将从库文件取得所需代码,复制到生成的可执行文件中。这种库成为静态库。 可执行文件中包含了库代码的一份完整拷贝,在编译过程中被载入程序中。 缺点就是多次使用就会有多份冗余拷贝,并且程序的更新、部署和发布很麻烦,如果静态库有更新,那么所有使用它的程序都需要重新编译、发布。 生成静态库的过程 将源文件生成 目标文件(完成到汇编的过程) $ gcc -c test.c -o test.o 使用ar命令将test.o打包成libtest.a静态库 $ ar rcs libtest.a test.o 查看静态库的具体内容, 静态库其实就是目标文件的集合 $ ar t libtest.a test.o 动态库 Windows:.dll 格式 Linux:.so 格式 动态库是在汇编过程生成的,加载动态库是在程序运行过程中 动态库和静态库类似,但是它并不在链接时将需要的二进制代码都“拷贝”到可执行文件中,而是仅仅“拷贝”一些重定位和符号表信息,这些信息可以在程序运行时完成真正的链接过程。也就是说,动态库在链接阶段没有被复制到程序中,而是在程序运行时由系统动态加载到内存中供程序调用。 系统只需要载入一次动态库,不同的程序可以得到内存中相同动态库的副本,因此节省了很多内存。 程序运行可执行文件加载动态库,需要动态的设置动态库地址,才能运行。即将动态库放置在可执行文件同级目录下。 生成动态库的过程 首先生成test.o目标文件。 $ gcc -c test.c 使用-share和-fPIC参数生成动态库。 $ gcc -shared -fPIC -o libtest.so test.o 编译可执行文件并链接动态库 $ gcc -o main main.c -L. -ltest 此时生成的可执行文件无法直接使用,需要动态设置动态库地址。 $ LD_LIBRARY_PATH. ./main 把动态库地址设置到当前目录下面即可。 动态库与静态库的区别 可执行文件大小不一样 从前面也可以观察到,静态链接的可执行文件要比动态链接的可执行文件要大得多,因为它将需要用到的代码从二进制文件中“拷贝”了一份,而动态库仅仅是复制了一些重定位和符号表信息。 占用磁盘大小不一样 如果有多个可执行文件,那么静态库中的同一个函数的代码就会被复制多份,而动态库只有一份,因此使用静态库占用的磁盘空间相对比动态库要大。 扩展性与兼容性不一样 如果静态库中某个函数的实现变了,那么可执行文件必须重新编译,而对于动态链接生成的可执行文件,只需要更新动态库本身即可,不需要重新编译可执行文件。正因如此,使用动态库的程序方便升级和部署。 依赖不一样 静态链接的可执行文件不需要依赖其他的内容即可运行,而动态链接的可执行文件必须依赖动态库的存在。所以如果你在安装一些软件的时候,提示某个动态库不存在的时候也就不奇怪了。 即便如此,系统中一班存在一些大量公用的库,所以使用动态库并不会有什么问题。 复杂性不一样 相对来讲,动态库的处理要比静态库要复杂,例如,如何在运行时确定地址?多个进程如何共享一个动态库?当然,作为调用者我们不需要关注。另外动态库版本的管理也是一项技术活。这也不在本文的讨论范围。 加载速度不一样 由于静态库在链接时就和可执行文件在一块了,而动态库在加载或者运行时才链接,因此,对于同样的程序,静态链接的要比动态链接加载更快。所以选择静态库还是动态库是空间和时间的考量。但是通常来说,牺牲这点性能来换取程序在空间上的节省和部署的灵活性时值得的。再加上局部性原理,牺牲的性能并不多。

2021年6月4日 0Comments 82Browse 1Like agedcat_xuanzai Read more

如何写 makefile 文件 什么是makefile? 在Linux环境下进行工程项目的开发,编译一般是通过make命令来完成。而make命令执行时,需要一个makefile文件,以告诉make命令需要怎么样的去编译和链接程序。 makefile 的规则  target ... : prerequisites ...     command     ...     ... target 可以是一个object file(目标文件),也可以是一个执行文件,还可以是一个标签(label)。对于标签这种特性,在后续的“伪目标”章节中会有叙述。 prerequisites 生成该target所依赖的文件和/或target command 该target要执行的命令(任意的shell命令) 这是一个文件的依赖关系,也就是说,target这一个或多个的目标文件依赖于prerequisites中的文件,其生成规则定义在command中。说白一点就是说:  prerequisites中如果有一个以上的文件比target文件要新的话,command所定义的命令就会被执行。 这就是makefile的规则,也就是makefile中最核心的内容。 一个示例 在这个示例中,我们的工程有8个c文件,和3个头文件,我们要写一个makefile来告诉make命令如何编译和链接这几个文件。我们的规则是: 如果这个工程没有编译过,那么我们的所有c文件都要编译并被链接。 如果这个工程的某几个c文件被修改,那么我们只编译被修改的c文件,并链接目标程序。 如果这个工程的头文件被改变了,那么我们需要编译引用了这几个头文件的c文件,并链接目标程序。 为了完成前面上述的那三个规则,我们的makefile 应该是下面的这个样子的。  edit : main.o kbd.o command.o display.o \         insert.o search.o files.o utils.o     cc -o edit main.o kbd.o command.o display.o \         insert.o search.o files.o utils.o  ​  main.o : main.c defs.h     cc -c main.c  kbd.o : kbd.c defs.h command.h     cc -c kbd.c  command.o : command.c defs.h command.h     cc -c command.c  display.o : display.c defs.h buffer.h     cc -c display.c  insert.o : insert.c defs.h buffer.h     cc -c insert.c  search.o : search.c defs.h buffer.h     cc -c search.c  files.o : files.c defs.h buffer.h command.h     cc -c files.c  utils.o : utils.c defs.h     cc -c utils.c  clean :     rm edit main.o kbd.o command.o display.o \         insert.o search.o files.o utils.o 反斜杠( \ )是换行符的意思。这样比较便于makefile的阅读。我们可以把这个内容保存在名字为“makefile”或“Makefile”的文件中,然后在该目录下直接输入命令 make 就可以生成执行文件edit。如果要删除执行文件和所有的中间目标文件,那么,只要简单地执行一下 make clean 就可以了。 在这个makefile中,目标文件(target)包含:执行文件edit和中间目标文件( *.o ),依赖文件(prerequisites)就是冒号后面的那些 .c 文件和 .h 文件。每一个 .o 文件都有一组依赖文件,而这些 .o 文件又是执行文件 edit 的依赖文件。依赖关系的实质就是说明了目标文件是由哪些文件生成的,换言之,目标文件是哪些文件更新的。 在定义好依赖关系后,后续的那一行定义了如何生成目标文件的操作系统命令,一定要以一个 Tab 键作为开头。记住,make并不管命令是怎么工作的,他只管执行所定义的命令。make会比较targets文件和prerequisites文件的修改日期,如果prerequisites文件的日期要比targets文件的日期要新,或者target不存在的话,那么,make就会执行后续定义的命令。 这里要说明一点的是, clean 不是一个文件,它只不过是一个动作名字,有点像c语言中的label一样,其冒号后什么也没有,那么,make就不会自动去找它的依赖性,也就不会自动执行其后所定义的命令。要执行其后的命令,就要在make命令后明显得指出这个label的名字。这样的方法非常有用,我们可以在一个makefile中定义不用的编译或是和编译无关的命令,比如程序的打包,程序的备份,等等。 make是如何工作的 在默认的方式下,也就是我们只输入 make 命令。那么, make会在当前目录下找名字叫“Makefile”或“makefile”的文件。 如果找到,它会找文件中的第一个目标文件(target),在上面的例子中,他会找到“edit”这个文件,并把这个文件作为最终的目标文件。 如果edit文件不存在,或是edit所依赖的后面的 .o 文件的文件修改时间要比 edit 这个文件新,那么,他就会执行后面所定义的命令来生成 edit 这个文件。 如果 edit 所依赖的 .o 文件也不存在,那么make会在当前文件中找目标为 .o 文件的依赖性,如果找到则再根据那一个规则生成 .o 文件。(这有点像一个堆栈的过程) 当然,你的C文件和H文件是存在的啦,于是make会生成 .o 文件,然后再用 .o 文件生成make的终极任务,也就是执行文件 edit 了。 这就是整个make的依赖性,make会一层又一层地去找文件的依赖关系,直到最终编译出第一个目标文件。在找寻的过程中,如果出现错误,比如最后被依赖的文件找不到,那么make就会直接退出,并报错,而对于所定义的命令的错误,或是编译不成功,make根本不理。make只管文件的依赖性,即,如果在我找了依赖关系之后,冒号后面的文件还是不在,那么对不起,我就不工作啦。 通过上述分析,我们知道,像clean这种,没有被第一个目标文件直接或间接关联,那么它后面所定义的命令将不会被自动执行,不过,我们可以显示要make执行。即命令—— make clean ,以此来清除所有的目标文件,以便重编译。 于是在我们编程中,如果这个工程已被编译过了,当我们修改了其中一个源文件,比如 file.c ,那么根据我们的依赖性,我们的目标 file.o 会被重编译(也就是在这个依性关系后面所定义的命令),于是 file.o 的文件也是最新的啦,于是 file.o 的文件修改时间要比 edit 要新,所以 edit 也会被重新链接了(详见 edit 目标文件后定义的命令)。 而如果我们改变了 command.h ,那么, kdb.o 、 command.o 和 files.o 都会被重编译,并且, edit 会被重链接。 makefile中使用变量 在上面的例子中,先让我们看看edit的规则:  edit…

2021年6月4日 0Comments 88Browse 2Like agedcat_xuanzai Read more
12