入門學習了Linux的系統編程和網路編程,應該寫一個小項目來練練手啦。這里模仿的是Github上一個開源項目:TinyWebServer。
項目地址:https://github.com/qinguoyi/TinyWebServer
非常感謝社長(TinyWebServer項目owner)的項目,項目代碼量不算多,但是麻雀雖小五臟俱全,是一個非常好的把學過的各個知識點串在一起的小項目!!這里想講一下實現過程以及其中的一些坑和收獲(我學習的是項目的raw version)。這篇(上)就先講項目最最最重要那些功能。閱讀本文或者說要完全理解這個項目需要有Linux系統編程基礎和網絡編程基礎,以及對計算機網路和http有一定了解。
博主水平十分有限(不是謙虛呀qaq),文章很可能有遺漏/錯誤,請大家也自行思考,歡迎指出討論。
(更新中~~~~~)
概括
項目分成Main模塊,epoll模塊,http模塊,lock模塊,threadpool模塊,log模塊,timer模塊,mysql模塊。先簡單講一下各個模塊的功能。main模塊就是服務器主函數,主要是負責把各個模塊組合協調工作。epoll模塊是寫epoll相關的函數。http模塊是最關鍵的,提供處理http請求以及處理后返回http響應的所有函數。后面的lock就是提供同步工具(互斥鎖/條件鎖/信號量),threadpool當然就是線程池提供工作線程來處理http請求,log日記模塊,mysql數據庫模塊提供數據庫服務,timer定時器模塊主要任務是負責定時清理長時間無反應的連接。
main模塊
首先由main開始,主函數要協調各個模塊進行工作,主要任務就是調用epoll函數監聽各種事件並對事件調用相應的處理模塊,接下啦詳細講一下:
在開始監聽之前我們先創建listenfd並用epoll對其監聽,然后我們開始正式工作,不斷while知道WebServer停止服務,在while中我們調用epoll_wait函數得到所有有反應的事件,我們對這些事件分成幾類來進行處理(根據事件的sockfd來判斷):
①新連接請求,判斷條件是sockfd == listenfd,那么沒什么好說的我們accept接受請求就是了,並且保存好用戶連接數據(ip,端口,connfd等),然后就把connfd掛到eoll上監聽它的讀事件。
②對端關閉,判斷條件是EPOLLRDHUP | EPOLLHUP | EPOLLERR,那么我們這邊也關閉該連接相關資源。
③讀事件且是信號事件,判斷條件是(sockfd==pipefd[0]) && (whatopt & EPOLLIN),這里為什么信號會當成epoll的讀事件?是因為統一了信號源,在信號的回調函數想管道里寫東西,然后epoll監聽管道,所以會監聽到讀事件,這里也許需要配合信號模塊的講解才容易理解。
④讀事件且是客戶端發送請求報文,判斷條件是EPOLLIN,那么這里是讀又不是信號,那么就是客戶端的請求報文啦,那么我們把這次的讀數據全部讀到我們提前為該客戶的准備的讀數據緩沖區,然后我們把一個任務插入到線程池的任務隊列中。於是我們就不用管了,工作線程自然會處理。
⑤寫事件,判斷條件是EPOLLOUT。WebServer只用一種寫事件就是我們的工作線程做好了請求報文的處理,並且已經搓好了響應報文放在了客戶的寫數據緩沖區中,那么我們的任務就是調用函數把響應報文發送給瀏覽器。
這里直接講main函數,可以看到整個服務器是怎么工作的,當然也有可能還沒有接觸到響應的各個模塊所以有些懵逼,但是這里先有個大概流程了解后面了解清除所有模塊工作之后回來會更加清晰。

1 while (!stop_server) 2 { 3 int total = epoll_wait(epollfd,events,MAX_EVENT_NUMBER,-1); 4 if (total < 0 && errno != EINTR) { 5 //把錯誤記錄到日志 6 break; 7 } 8 9 for (int i = 0; i < total; i++) { 10 int sockfd = events[i].data.fd; 11 int whatopt = events[i].events; 12 13 //有新連接請求事件 14 if (sockfd == listenfd) { 15 struct sockaddr_in client_address; 16 socklen_t client_address_len = sizeof(client_address); 17 18 //LT 19 int connfd = accept(listenfd, (struct sockaddr*)&client_address, &client_address_len); 20 if (connfd < 0) { 21 //accept錯誤 22 continue; 23 } 24 if (http_conn::m_user_count >= MAX_FD) { 25 //用戶數量超過最大描述符了 26 continue; 27 } 28 29 in_addr client_ip; 30 memcpy(&client_ip, &client_address.sin_addr.s_addr, 4); 31 printf("ip:%s connect\n", inet_ntoa(client_ip) ); 32 33 // 初始化新客戶,並在這里面把課后掛到epoll監聽樹上 34 users[connfd].init(connfd, client_address); 35 36 //創造timer和client_data 37 user_timer[connfd].address = client_address; 38 user_timer[connfd].sockfd = connfd; 39 40 util_timer* timer = new util_timer; 41 timer->user_data = &user_timer[connfd]; 42 timer->cb_func = cb_func; 43 timer->expire = time(NULL) + 6 * TIMESLOT; 44 45 user_timer[connfd].timer = timer; 46 //上面創造好了timer,加入到鏈表中 47 timer_lst.add_timer(timer); 48 } 49 //對端關閉連接事件(EPOLLRDHUP | EPOLLHUP 這兩個是關閉) 50 else if (whatopt & (EPOLLRDHUP | EPOLLHUP | EPOLLERR)) { 51 //對端關閉了,我們這邊也關閉然后取消定時器 52 util_timer* timer = user_timer[sockfd].timer; 53 timer->cb_func(&user_timer[sockfd]); 54 if (timer) timer_lst.del_timer(timer); 55 } 56 //因為統一了事件源,信號處理當成讀事件來處理 57 //怎么統一?就是信號回調函數哪里不立即處理而是寫到:pipe的寫端 58 else if ((sockfd==pipefd[0]) && (whatopt & EPOLLIN)) { 59 int sig; 60 char signals[1024]; 61 int ret = recv(pipefd[0], signals, sizeof(signals), 0); 62 if (ret == -1) continue; 63 if (ret == 0) continue; 64 //在這里處理信號 65 for (int i = 0; i < ret; i++) { 66 switch (signals[i]) 67 { 68 case SIGALRM: 69 timeout = true; 70 break; 71 case SIGTERM: 72 stop_server = true; 73 default: 74 break; 75 } 76 } 77 } 78 /*輸入事件,理想步驟是: 79 process->porcess_read(不斷parse_line->parse_status_line/parse_headers/parse_content 80 ->do_request)->process_write(add_line/heads/content...) 81 ->把報文搓到輸出緩沖區 82 */ 83 else if (whatopt & EPOLLIN) { 84 //開始處理這個瀏覽器請求 85 util_timer* timer = user_timer[sockfd].timer; 86 if (users[sockfd].read_once()) { //1,把所有數據讀進來 87 pool->append(users + sockfd); //2,讀完之后把往線程池任務隊列放入一個任務,這里process函數最后會添加監聽寫時間 88 89 //因為有了新請求,所以把這個客戶的不活躍事件延后 90 //延后時間之后做出位置調整 91 if (timer) { 92 timer->expire = time(NULL) + 6 * TIMESLOT; 93 timer_lst.adjust_timer(timer); 94 } 95 } 96 //read_once()失敗,關閉連接吧 97 else { 98 timer->cb_func(&user_timer[sockfd]); 99 if (timer) timer_lst.del_timer(timer); 100 } 101 } 102 //輸出事件 103 else if (whatopt & EPOLLOUT) { 104 util_timer* timer = user_timer[sockfd].timer; 105 //在上面讀事件已經搓好響應報文就等這里write把輸出緩沖區發送給瀏覽器 106 if (users[sockfd].write()) { //write函數最后會重新監聽讀事件 107 //跟讀事件一樣,延后這個客戶的不活躍事件 108 if (timer) { 109 timer->expire = time(NULL) + 6 * TIMESLOT; 110 timer_lst.adjust_timer(timer); 111 } 112 } 113 else { //這里的話就是write發送給瀏覽器失敗,關閉連接 114 timer->cb_func(&user_timer[sockfd]); 115 if (timer) timer_lst.del_timer(timer); 116 } 117 } 118 } 119 120 if (timeout) { 121 timer_handler(); 122 printf("Now %d clients connect\n", http_conn::m_user_count); 123 timeout = false; 124 } 125 }
epoll模塊
epoll模塊就是和epoll相關的我們需要的函數在這里定義,這里比較簡答就不細講了,看代碼注釋肯定能懂。

1 int epoll_myinit() { 2 int epollfd = epoll_create(5); 3 assert(epollfd != -1); 4 return epollfd; 5 } 6 7 //把文件描述符fd設為非阻塞 8 int setnoblocking(int fd) { 9 int old_option = fcntl(fd, F_GETFL); 10 int new_option = old_option | O_NONBLOCK; 11 fcntl(fd, F_SETFL, new_option); 12 return old_option; 13 } 14 15 //把fd添加到監聽紅黑樹epollfd上 16 void addfd(int epollfd, int fd, bool oneshot) { 17 epoll_event event; 18 event.data.fd = fd; 19 20 event.events = EPOLLIN | EPOLLRDHUP; //讀事件 21 //EPOLLRDHUP 表示讀關閉。 22 //1 對端發送 FIN (對端調用close 或者 shutdown(SHUT_WR)). 23 //2 本端調用 shutdown(SHUT_RD). 當然,關閉 SHUT_RD 的場景很少。 24 25 if (oneshot) 26 event.events |= EPOLLONESHOT; 27 /*eppll 即使使用ET模式,一個socket上的某個事件還是可能被觸發多次,采用線程城池的方式來處理事件,可能一個socket同時被多個線程處理 28 如果對描述符socket注冊了EPOLLONESHOT事件,那么操作系統最多觸發其上注冊的一個可讀、可寫或者異常事件,且只觸發一次。。想要下次再觸發則必須使用epoll_ctl重置該描述符上注冊的事件,包括EPOLLONESHOT 事件。 29 EPOLLONESHOT:只監聽一次事件,當監聽完這次事件之后,如果還需要繼續監聽這個socket的話,需要再次把這個socket加入到EPOLL隊列里 30 */ 31 32 epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event); 33 setnoblocking(fd); //設置為非阻塞,如果ET是必須的 34 } 35 //把fd從監聽紅黑樹epollfd上摘下來 36 void removefd(int epollfd, int fd) { 37 epoll_ctl(epollfd, EPOLL_CTL_DEL, fd,0); 38 close(fd); 39 } 40 //將事件重置為EPOLLONESHOT 41 void modfd(int epollfd, int fd, int ev) { 42 epoll_event event; 43 event.data.fd = fd; 44 //LT 45 event.events = ev | EPOLLONESHOT | EPOLLRDHUP; 46 epoll_ctl(epollfd, EPOLL_CTL_MOD, fd, &event); 47 }
locker模塊
locker模塊包裝了一些我們做線程同步的工具類,具體有:信號量類,互斥鎖,條件變量類。沒有什么太特別的,就是把這些工具函數將他們的錯誤處理包裝起來方便使用。

1 #ifndef LOCKER_H 2 #define LOCKER_H 3 4 #include<exception> 5 #include<pthread.h> 6 #include<semaphore.h> 7 8 //一個簡單的信號量類 9 class sem 10 { 11 public: 12 sem() { 13 if (sem_init(&m_sem, 0, 0) != 0) 14 throw std::exception(); 15 } 16 sem(int num) { 17 if (sem_init(&m_sem, 0, num) != 0) 18 throw std::exception(); 19 } 20 ~sem() { 21 sem_destroy(&m_sem); 22 } 23 24 //加鎖與解鎖 25 bool wait() { 26 return sem_wait(&m_sem) == 0; 27 } 28 bool post() { 29 return sem_post(&m_sem) == 0; 30 } 31 32 private: 33 sem_t m_sem; 34 }; 35 36 37 //簡單的互斥鎖 38 class locker 39 { 40 public: 41 locker() { 42 if (pthread_mutex_init(&m_mutex,NULL) != 0) 43 throw std::exception(); 44 } 45 ~locker() { 46 pthread_mutex_destroy(&m_mutex); 47 } 48 49 bool lock() { 50 return pthread_mutex_lock(&m_mutex) == 0; 51 } 52 bool unlock() { 53 return pthread_mutex_unlock(&m_mutex) == 0; 54 } 55 pthread_mutex_t* get() { 56 return &m_mutex; 57 } 58 59 private: 60 pthread_mutex_t m_mutex; 61 }; 62 63 64 //簡單的條件變量 65 class cond 66 { 67 public: 68 cond() { 69 if (pthread_cond_init(&m_cond, NULL) != 0) 70 throw std::exception(); 71 } 72 ~cond() { 73 pthread_cond_destroy(&m_cond); 74 } 75 76 //設置條件變量 77 bool wait(pthread_mutex_t* m_mutex) { 78 return pthread_cond_wait(&m_cond, m_mutex); 79 } 80 bool timewait(pthread_mutex_t* m_mutex, struct timespec t) { 81 return pthread_cond_timedwait(&m_cond, m_mutex, &t)==0; 82 } 83 //條件變量滿足,喚醒阻塞在m_mutex互斥量的線程 84 bool signal() { 85 return pthread_cond_signal(&m_cond) == 0; 86 } 87 bool broadcast() { 88 return pthread_cond_broadcast(&m_cond) == 0; 89 } 90 91 private: 92 pthread_cond_t m_cond; 93 }; 94 95 #endif // !LOCKER_H
threadpool模塊
一個web服務器幾乎離不開多線程了,在main那里我們說到main把所有讀時間能讀到的數據都存放在客戶讀緩沖區中,然后就插入任務到任務隊列等待線程去完成。我們來仔細講一下:
線程池類有兩個最為關鍵的成員:
pthread_t* m_threads; //線程池數組
std::list<T*> m_workqueue; //請求隊列
什么是請求隊列,就是一個存儲任務的list,我們在main函數把新任務放到list的尾部,然后所有線程爭奪list中的任務(這里要使用條件變量),爭奪到的線程先對任務隊列加互斥鎖然后從list頭取出任務結構體,在這個任務結構體內有一個回調函數,這個函數就是真正的工作(包括解析http請求報文,對請求資源的檢查,搓響應報文一條龍)當然這個函數我們放在http模塊以更加模塊化,從線程的角度就是我們拿到任務然后調用這個函數,線程就是在漫長的這個函數中度過了。
然后是線程池數組,這個線程池還是比較簡單的線程池(沒有對線程的動態刪減等等),那么我們就是在線程池構造的時候就創建好約定個數的線程儲存在線程數組里,並且把線程detach掉,這樣我們就不需要對線程進行回收等等操作。線程的關鍵是線程的工作函數run(),這個函數不斷while循環直到被條件變量喚醒然后上鎖從list尾取出任務,開始執行任務(函數是porcess,看下個模塊)

1 #ifndef THREADPOOL_H 2 #define THREADPOOL_H 3 4 #include<cstdio> 5 #include<pthread.h> 6 #include<exception> 7 #include<list> 8 9 #include "../lock/locker.h" 10 #include"../CGImysql/sql_connection_pool.h" 11 12 template<typename T> 13 class threadpool 14 { 15 public: 16 /*thread_number是線程池中線程的數量,max_requests是請求隊列中最多允許的、等待處理的請求的數量*/ 17 threadpool(connection_pool* connPool, int thread_number = 8, int max_request = 10000); 18 ~threadpool(); 19 bool append(T* request); 20 21 private: 22 /*工作線程運行的函數,它不斷從工作隊列中取出任務並執行之*/ 23 static void* worker(void* arg); 24 void run(); 25 26 private: 27 int m_thread_number; //線程池線程數 28 int m_max_requests; //請求隊列的最大請求數 29 30 pthread_t* m_threads; //線程池 數組 31 std::list<T*> m_workqueue; //請求 隊列 32 33 locker m_queuelocker; //請求隊列的互斥鎖 34 sem m_queuestat; //請求隊列的信號量(可以看出要處理的任務數) 35 36 bool m_stop; //線程池結束標志 37 connection_pool* m_connPool; //數據庫連接池 38 }; 39 40 //線程池構造函數 41 template<typename T> 42 threadpool<T>::threadpool(connection_pool* connPool, int thread_number, int max_request) : 43 m_thread_number(thread_number), m_max_requests(max_request), m_stop(false), m_threads(NULL), m_connPool(connPool) { 44 if (thread_number <= 0 || max_request <= 0) //不合理的線程數量和請求隊列數量 45 throw std::exception(); 46 m_threads = new pthread_t[m_thread_number]; 47 if (!m_threads) 48 throw std::exception(); 49 //創造thread_number個線程並且存儲起來 50 for (int i = 0; i < thread_number; i++) { 51 if (pthread_create(m_threads + i, NULL, worker, this) != 0) { 52 delete[] m_threads; //失敗 53 throw std::exception(); 54 } 55 if (pthread_detach(m_threads[i])) { 56 delete[] m_threads; //失敗 57 throw std::exception(); 58 } 59 } 60 } 61 62 //線程池析構函數 63 template<typename T> 64 threadpool<T>::~threadpool() { 65 delete[] m_threads; 66 m_stop = true; 67 } 68 69 //將“待辦工作”加入到請求隊列 70 template<typename T> 71 bool threadpool<T>::append(T *request) { 72 m_queuelocker.lock(); 73 if (m_workqueue.size() > m_max_requests) { 74 m_queuelocker.unlock(); 75 return false; 76 } 77 m_workqueue.push_back(request); 78 m_queuelocker.unlock(); 79 m_queuestat.post(); 80 return true; 81 } 82 83 //線程回調函數/工作函數,arg其實是this 84 template<typename T> 85 void* threadpool<T>::worker(void *arg) { 86 threadpool* pool = (threadpool*)arg; 87 pool->run(); 88 return pool; 89 } 90 91 //回調函數會調用這個函數工作 92 //工作線程就是不斷地等任務隊列有新任務,然后就加鎖取任務->取到任務解鎖->執行任務 93 template<typename T> 94 void threadpool<T>::run() { 95 while (!m_stop) { 96 //請求隊列長度--,互斥鎖鎖住 97 m_queuestat.wait(); 98 m_queuelocker.lock(); 99 100 if (m_workqueue.empty()) { 101 m_queuelocker.unlock(); 102 continue; 103 } 104 105 T* request = m_workqueue.front(); 106 m_workqueue.pop_front(); 107 108 m_queuelocker.unlock(); 109 110 if (!request) continue; 111 112 // 113 connectionRAII mysqlcon(&request->mysql, m_connPool); 114 115 request->process(); 116 } 117 } 118 119 120 #endif // THREADPOOL_H
線程池的數量是不是越多服務器的性能越高呢?其實不是這樣的,簡單來說線程數量和計算機的CPU核心數成一定關系時,表現會較好。從網上找到一個回答(出處不詳
http模塊
好的到了這里我們終於到了最關鍵的http模塊了,在http模塊我們將完成最關鍵的處理http請求報文和搓http響應報文的工作。如果一個個函數細細講這一塊可以講很久很久。。。
我們先看看主要函數以及它們的協調合作:
我們先理解這一塊使用到的“狀態機設計模式”,我們學過UML里的狀態圖,那么我們應該很容易理解這個設計模式。簡單理解就是,我們經常會遇到需要根據不同的情況作出不同的處理的情況,這時候我們寫出大量的if else使得邏輯十分混亂。那么我們可以這樣設計:我們在類里面設計一個狀態,並且允許一個對象在其內部狀態改變時改變它的行為,對象看起來似乎修改了它的類。感覺說起來還是比較抽象,看代碼會比較容易理解,其實就是看狀態調用不同的函數。
在http中我們如何使用狀態機,我們有兩個狀態機:主狀態機和從狀態機。
//主狀態機的狀態:解析請求行 解析請求頭 解析消息體(僅用於解析POST請求) enum CHECK_STATE { CHECK_STATE_REQUESTLINE = 0, CHECK_STATE_HEADER, CHECK_STATE_CONTENT }; //報文解析的結果:請求不完整需要繼續讀取請求報文數據 獲得了完整的HTTP請求 HTTP請求報文有語法錯誤 服務器內部錯誤,該結果在主狀態機邏輯switch的default下,一般不會觸發 enum HTTP_CODE { NO_REQUEST, GET_REQUEST, BAD_REQUEST, NO_RESOURCE, FORBIDDEN_REQUEST, FILE_REQUEST, INTERNAL_ERROR, CLOSED_CONNECTION }; //從狀態機的狀態:完整讀取一行 報文語法有誤 讀取的行不完整 enum LINE_STATUS { LINE_OK = 0, LINE_BAD, LINE_OPEN };
這一部分知識強烈建議看一下社長公眾號系列文章,我們需要重點理解主狀態機和從狀態機,我的個人理解是主狀態機是更為宏觀一點的他主要關注當前解析到 請求行 / 請求頭 / 請求主體 ?那么我們的代碼就要根據這個主狀態機的狀態判斷當前的解析進度從而判斷當前進度下一步要做的動作(舉個例子:比如我現在是請求頭狀態並且也發現現在已經把最后一行請求頭解析完了,那么我們就變換主狀態機狀態,這時下一次代碼就能判斷到當前解析到請求主體了所以調用相關的函數)。
那么從狀態機又怎么理解呢?從狀態機就是更加聚焦於一行行,着眼更加細致,他關心的是當前這一行讀完整了/不完整/格式有誤,亦即從狀態機關注一行的解析狀態。(那么顯然這里可以想到其實每一個主狀態機狀態可能對應多輪的從狀態改變,類似與包含關系),所以從狀態機的函數會關注當前字符是什么,根據這個字符判斷當前是讀完了嗎是格式錯誤嗎等等。
我們從狀態機的角度看看這個函數調用:
http模塊就講到這里了,具體每個函數實現還是得認真看代碼,有很多很多細節值得學習,這里怕是講不完。
OK到這里我們就講了TinyWebServer的前五個模塊了,寫成這樣其實webserver已經能夠基礎工作了,監聽/請求/響應都可以完成了。后面我們要對這個服務器增加更多的模塊,使得他的性能上升和提供更多的功能。
參考資料:
TinyWebServer項目地址:https://github.com/qinguoyi/TinyWebServer