啟動:父進程啟動;子進程啟動;網絡架構。
每個父進程攜帶N個子進程,子進程負責處理業務邏輯和其它數據,而父進程只是將客戶端的請求路由到各個子進程,路由的策略非常簡單,父進程將請求包按照輪流的法則分發到這N個子進程。
子進程接收到請求包的時候,它便開始處理,處理完后再將結果反還給父進程。注意,子進程并不處理網絡連接,它并不知道請求包的源的信息,它只處理業務,相反地,父進程并不知道請求包的內容,它的任務就是處理連接。
父子進程之間通過共享內存進行通信,具體來講就是父進程將請求包放入和對應子進程共享的內存中,然后通過一個管道喚醒子進程,子進程探測到管道消息以后就從共享內存將請求拉出來然后進行處理,處理完畢后再將結果放回到共享內存,然后同樣喚醒父進程,父進程被喚醒之后便拉出子進程的回復數據,最后通過它自己保存的連接返回給客戶端。
這個服務器解除了接收數據和處理數據之間的耦合,便于進行任何一邊的擴展,不像那種消息映射服務器,直接在本進程內部通過分發回調函數來處理業務邏輯,或者用線程的方式進行處理,線程的方式雖然解決了吞吐量的問題,但是無法解決穩定性的問題,必須默認所有的數據都是安全的或者開發出繁復的處理邏輯來處理異常情況,額外增加了服務器的負擔。子進程的關于業務邏輯的處理方式非常類似于那種消息映射服務器,不同之處在于,典型的消息映射服務器是從網絡上將數據拉回,而該online服務器卻是從共享內存中將數據拉回,多了共享內存這么一個中間層。
關于業務邏輯的處理還有一個類似的層次,就是online子進程和數據庫之間的關系,它們通過一個數據庫代理(DBProxy)來將子進程的處理邏輯和數據庫之間的耦合解除,并且這數據庫代理還可以隱藏數據庫的訪問接口,只有代理知道后端連接了什么數據庫,而處理邏輯不必知道,它只需要將訪庫請求作為網絡請求發送給數據庫代理就好了,然后用消息映射服務器的方式處理數據庫代理的回復。數據庫只管保存數據,而不管這些數據之間的除了關系模型之外的額外事宜,比如有效性驗證之類的,所有的數據驗證和處理工作在online子進程那里進行。這樣處理的優點就是易于擴展新業務,缺點就是要來回幾次的進程訪庫,因為每次只取當次的數據,在業務處理過程中可能還需要別的數據…不過缺點可以通過高速網絡和高性能數據庫以及數據庫代理服務器來彌補。
for ( ; i != bc->bind_num; ++i ) {
bind_config_elem_t* bc_elem = &(bc->configs[i]);
shmq_create(bc_elem); //通過mmap的方式創建共享內存
… //錯誤處理
} else if (pid > 0) {
close_shmq_pipe(bc, i, 0);
do_add_conn(bc_elem->sendq.pipe_handles[0], PIPE_TYPE_FD, 0, bc_elem);
} else {
run_worker_process(bc, i, i + 1);
}
}
run_worker_process函數開始了子進程的歷程,可以看到最后這個函數調用了一個叫做net_loop的無限循環,這個函數在父進程初始化完畢后也最終調用,原型如下:
int net_loop(int timeout, int max_len, int is_conn);
該函數通過最后一個參數is_conn來區分是子進程還是父進程,函數內部實現也是通過該參數一分為二的,online的父進程負責網絡收發,主要是基于epoll的,epoll已經被證明擁有很高的性能,在linux平臺上的性能測試已經超越了原來的poll/select模型,甚至比windows的IO完成端口在大負載,高并發環境下表現更加出色。在net_loop中用epoll_wait等待有事件的文件描述符,然后判斷文件描述符的類型(套結字在創建之初就將描述符和類型等信息打包成一個數據結構了),如果是管道文件的事件,那么肯定是不需要處理數據的,僅僅察看事件類型以及判斷是否父進程就可以判斷發生了什么事了,由于子進程根本就不會將套結字描述符加入到epoll監控向量,因此子進程只能有管道類型的事件發生,注意這里不涉及online子進程和DB的通信。接下來的net_loop中關于epoll的處理流程就是父進程的事了,具體過程就是處理套結字類型的文件描述符了,就是從套結字接收數據,然后放到和一個子進程共享的內存區域中,最后往子進程管道里寫一個數據,告訴子進程現在該處理業務邏輯了,子進程在net_loop中監控到管道事件之后,最終調用net_loop最后的handle_recv_queue()函數,該函數開始處理業務邏輯:
if(!is_conn) {
#ifdef USE_CMD_QUEUE
handle_cmd_busy_sprite(); //handle the busy sprite list first
#endif
handle_recv_queue();
handle_timer();
}
以上是net_loop的大致流程,對于父進程如何將請求路由給子進程有兩種選擇,一種是父進程網絡服務器按照某種策略比如負載均衡采取輪換路由,另一種就是將選擇留給用戶,用戶登錄online之前首先登錄一個switch服務器,自行選擇online子進程,每個online子進程都有一個ID,用戶選擇后就用這個ID作為數據打包,另外switch服務器上的online子進程鏈表中包含了足夠的其對應于父進程的IP地址和端口信息,然后向online子進程對應的父進程發送LOGIN包,父進程在net_loop中最終調用net_recv,然后解出LOGIN包,由于該包中包含了其子進程的id,而這個id又和其與子進程的共享內存相關聯,一個數據結構最起碼關聯了父進程接收的套結字描述符,子進程ID,父子進程的共享內存緩沖區這三個元素。
關鍵數據結構:
typedef struct bind_config_elem {
int online_id;
char online_name[16];
char bind_ip[16]; //邦定的ip地址
in_port_t bind_port; //邦定的端口
char gameserv_ip[16]; //游戲服務器的ip
in_port_t gameserv_port;
char gameserv_test_ip[16];
in_port_t gameserv_test_port;
struct shm_queue sendq; //發送緩沖區,被分割成一個一個的塊,因此叫隊列
struct shm_queue recvq; //接收緩沖區,被分割成一個一個的塊,因此叫隊列
} bind_config_elem_t;
該結構描述了每一個傳輸套結字都應該擁有的一個結構,也就是每一個子進程一個這樣的結構
typedef struct bind_config {
int online_start_id;
int bind_num;
bind_config_elem_t configs[MAX_LISTEN_FDS];
} bind_config_t;
這個結構是上面結構的容器,main中的bind_config_elem_t* bc_elem = &(bc->configs[i]);體現了一切,所有的一切都是從配置文件中讀取的。
typedef struct shm_head {
volatile int head;
volatile int tail;
atomic_t blk_cnt;
} __attribute__ ((packed)) shm_head_t;
這個結構分割了一個緩沖區,將一個連續的緩沖區分割成了一個隊列
struct shm_queue {
shm_head_t* addr;
u_int length;
int pipe_handles[2];
};
這個結構代表了一個緩沖區,分割的過程在shm_head_t中體現。
struct epinfo {
struct fdinfo *fds;
struct epoll_event *evs;
struct list_head close_head;
struct list_head etin_head;
int epfd;
int maxfd;
int fdsize;
int count;
};
這個結構代表了epoll事件。
在LOGIN包被父進程解析到的時候:
if ((ntohl(proto->cmd) == PROTO_LOGIN) && (epi.fds[fd].bc_elem == 0) )為真,接著:
uint16_t online_id = ntohs(*(uint16_t*)(proto->body)); //得到用戶選擇的online_id
…
epi.fds[fd].bc_elem = &(bc->configs[online_id - bc->online_start_id]); //得到該id對應的config結構體。
得到了bind_config_elem_t結構體之后就可以將請求包轉發到從該結構體中取出的共享內存緩沖區了,然后將請求包放到這個內存中。所有的請求包中,LOGIN請求包是父進程直接處理的,后續的游戲邏輯請求包由子進程處理,另外子進程雖然不處理網絡連接,但是對于和數據庫代理服務器和switch中心跳服務器的連接還是要自己處理的,因此子進程中也有網絡相關的內容,在net_rcv中有以下片斷:
if (!is_conn) {
handle_process(epi.fds[fd].cb.recvptr, epi.fds[fd].cb.rcvprotlen, fd, 0);
}
這個就是直接處理數據庫代理以及心跳的處理過程。另外關于網絡架構中還有一點就是鏈表的使用,在net_rcv中首先調用do_read_conn讀取網絡數據,但是一旦當前積壓的未處理的數據達到了一個最大值的時候,后續的請求就要丟到鏈表中,然后在下一輪net_loop中接收新的數據前優先處理之;在net_loop中有一句:
if (is_conn) handle_send_queue();
該句的意思就是說,如果是父進程,那么首先處理發送隊列,這些發送隊列中的數據都是子進程放入的請求包的回復,父進程優先將這些回復返回給各個客戶端:
static inline void handle_send_queue()
{
struct shm_block *mb;
struct shm_queue *q;
int i = 0;
for ( ; i != bindconf.bind_num; ++i ) {
q = &(bindconf.configs[i].sendq);
while ( shmq_pop(q, &mb) == 0 ) {
schedule_output(mb);
}
}
}
雖然這個過程比較優先,但是更優先是前面說的過程,就是處理積壓鏈表,下面片斷在上面的之前調用:
list_for_each_safe (p, l, &epi.close_head) { //優先便利需要關閉的套結字,第一時間關閉連接
fi = list_entry (p, struct fdinfo, list);
if (fi->cb.sendlen > 0)
do_write_conn (fi->sockfd);
do_del_conn (fi->sockfd, 0);
}
list_for_each_safe (p, l, &epi.etin_head) { //優先處理積壓隊列,提高響應速度
fi = list_entry (p, struct fdinfo, list);
if (net_recv(fi->sockfd, max_len, is_conn) == -1)
do_del_conn(fi->sockfd, is_conn);
}
該服務器中大量運用了鏈表,此鏈表的定義就是list_head,是從linux內核中抽取出來的。
接收新連接的時候,在net_loop中:
if (epi.fds[fd].type == LISTEN_TYPE_FD) {
while (do_open_conn(fd, is_conn) > 0);
接收了新的連接,并且加入了一個列表,將新連接的套結字描述符和一個空的bind_config_elem_t相關聯,注意此時并沒有初始化這個bind_config_elem_t,因為在LOGIN包到來之前還不知道和哪一個bind_config_elem_t相關聯,該函數僅僅初始化了一個epi結構。
?