天天看點

online遊戲伺服器架構--網絡架構

啟動:父程序啟動;子程序啟動;網絡架構。

每個父程序攜帶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) { //優先處理積壓隊列,提高響應速度

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結構。

 本文轉自 dog250 51CTO部落格,原文連結:http://blog.51cto.com/dog250/1274110