對于我一個(gè)python初學(xué)者來說,今天花了3個(gè)小時(shí)左右的時(shí)間完整搞定了圖形驗(yàn)證碼的識(shí)別功能,并且可以正常登錄成功。
"MSG":"SUCCESS",
作者:jaydenwen,騰訊 pcg 后臺(tái)開發(fā)工程師
在互聯(lián)網(wǎng)中提起網(wǎng)絡(luò),我們都會(huì)避免不了討論高并發(fā)、百萬連接。而此處的百萬連接的實(shí)現(xiàn),脫離不了網(wǎng)絡(luò) IO 的選擇,因此本文作為一篇個(gè)人學(xué)習(xí)的筆記,特此進(jìn)行記錄一下整個(gè)網(wǎng)絡(luò) IO 的發(fā)展演變過程。以及目前廣泛使用的網(wǎng)絡(luò)模型。
在本節(jié)內(nèi)容中,我們將一步一步介紹網(wǎng)絡(luò) IO 的演變發(fā)展過程。介紹完發(fā)展過程后,再對網(wǎng)絡(luò) IO 中幾組容易混淆的概念進(jìn)行對比、分析。
通常,我們在此討論的網(wǎng)絡(luò) IO 一般都是針對 linux 操作系統(tǒng)而言。網(wǎng)絡(luò) IO 的發(fā)展過程是隨著 linux 的內(nèi)核演變而變化,因此網(wǎng)絡(luò) IO 大致可以分為如下幾個(gè)階段:
1. 阻塞 IO(BIO)
2. 非阻塞 IO(NIO)
3. IO 多路復(fù)用第一版(select/poll)
4. IO 多路復(fù)用第二版(epoll)
5. 異步 IO(AIO)
而每一個(gè)階段,都是因?yàn)楫?dāng)前的網(wǎng)絡(luò)有一些缺陷,因此又在不斷改進(jìn)該缺陷。這是網(wǎng)絡(luò) IO 一直演變過程中的本質(zhì)。下面將對上述幾個(gè)階段進(jìn)行介紹,并對每個(gè)階段的網(wǎng)絡(luò) IO 解決了哪些問題、優(yōu)點(diǎn)、缺點(diǎn)進(jìn)行剖析。
1.2 網(wǎng)絡(luò)的兩個(gè)階段
在網(wǎng)絡(luò)中,我們通常可以將其廣義上劃分為以下兩個(gè)階段:
第一階段:硬件接口到內(nèi)核態(tài)
第二階段:內(nèi)核態(tài)到用戶態(tài)
本人理解:我們通常上網(wǎng),大部分?jǐn)?shù)據(jù)都是通過網(wǎng)線傳遞的。因此對于兩臺(tái)計(jì)算機(jī)而言,要進(jìn)行網(wǎng)絡(luò)通信,其數(shù)據(jù)都是先從應(yīng)用程序傳遞到傳輸層(TCP/UDP)到達(dá)內(nèi)核態(tài),然后再到網(wǎng)絡(luò)層、數(shù)據(jù)鏈路層、物理層,接著數(shù)據(jù)傳遞到硬件網(wǎng)卡,最后通過網(wǎng)絡(luò)傳輸介質(zhì)傳遞到對端機(jī)器的網(wǎng)卡,然后再一步一步數(shù)據(jù)從網(wǎng)卡傳遞到內(nèi)核態(tài),最后再拷貝到用戶態(tài)。
根據(jù) 1.2 節(jié)的內(nèi)容,我們可以知道,網(wǎng)絡(luò)中的數(shù)據(jù)傳輸從網(wǎng)絡(luò)傳輸介質(zhì)到達(dá)目的機(jī)器,需要如上兩個(gè)階段。此處我們把從硬件到內(nèi)核態(tài)這一階段,是否發(fā)生阻塞等待,可以將網(wǎng)絡(luò)分為阻塞 IO和非阻塞 IO。如果用戶發(fā)起了讀寫請求,但內(nèi)核態(tài)數(shù)據(jù)還未準(zhǔn)備就緒,該階段不會(huì)阻塞用戶操作,內(nèi)核立馬返回,則稱為非阻塞 IO。如果該階段一直阻塞用戶操作。直到內(nèi)核態(tài)數(shù)據(jù)準(zhǔn)備就緒,才返回。這種方式稱為阻塞 IO。
因此,區(qū)分阻塞 IO 和非阻塞 IO 主要看第一階段是否阻塞用戶操作。
從前面我們知道了,數(shù)據(jù)的傳遞需要兩個(gè)階段,在此處只要任何一個(gè)階段會(huì)阻塞用戶請求,都將其稱為同步 IO,兩個(gè)階段都不阻塞,則稱為異步 IO。
在目前所有的操作系統(tǒng)中,linux 中的 epoll、mac 的 kqueue 都屬于同步 IO,因?yàn)槠湓诘诙A段(數(shù)據(jù)從內(nèi)核態(tài)到用戶態(tài))都會(huì)發(fā)生拷貝阻塞。而只有 windows 中的 IOCP 才真正屬于異步 IO,即 AIO。
在本節(jié),我們將介紹最初的阻塞 IO,阻塞 IO 英文為 blocking IO,又稱為 BIO。根據(jù)前面的介紹,阻塞 IO 主要指的是第一階段(硬件網(wǎng)卡到內(nèi)核態(tài))。
阻塞 IO,顧名思義當(dāng)用戶發(fā)生了系統(tǒng)調(diào)用后,如果數(shù)據(jù)未從網(wǎng)卡到達(dá)內(nèi)核態(tài),內(nèi)核態(tài)數(shù)據(jù)未準(zhǔn)備好,此時(shí)會(huì)一直阻塞。直到數(shù)據(jù)就緒,然后從內(nèi)核態(tài)拷貝到用戶態(tài)再返回。具體過程可以參考 2.2 的圖示。
在一般使用阻塞 IO 時(shí),都需要配置多線程來使用,最常見的模型是阻塞 IO+多線程,每個(gè)連接一個(gè)單獨(dú)的線程進(jìn)行處理。
我們知道,一般一個(gè)程序可以開辟的線程是有限的,而且開辟線程的開銷也是比較大的。也正是這種方式,會(huì)導(dǎo)致一個(gè)應(yīng)用程序可以處理的客戶端請求受限。面對百萬連接的情況,是無法處理。
既然發(fā)現(xiàn)了問題,分析了問題,那就得解決問題。既然阻塞 IO 有問題,本質(zhì)是由于其阻塞導(dǎo)致的,因此自然而然引出了下面即將介紹的主角:非阻塞 IO
非阻塞 IO 是為了解決前面提到的阻塞 IO 的缺陷而引出的,下面我們將介紹非阻塞 IO 的過程。
非阻塞 IO:見名知意,就是在第一階段(網(wǎng)卡-內(nèi)核態(tài))數(shù)據(jù)未到達(dá)時(shí)不等待,然后直接返回。因此非阻塞 IO 需要不斷的用戶發(fā)起請求,詢問內(nèi)核數(shù)據(jù)好了沒,好了沒。
非阻塞 IO 是需要系統(tǒng)內(nèi)核支持的,在創(chuàng)建了連接后,可以調(diào)用 setsockop 設(shè)置 noblocking
正如前面提到的,非阻塞 IO 解決了阻塞 IO每個(gè)連接一個(gè)線程處理的問題,所以其最大的優(yōu)點(diǎn)就是 一個(gè)線程可以處理多個(gè)連接,這也是其非阻塞決定的。
但這種模式,也有一個(gè)問題,就是需要用戶多次發(fā)起系統(tǒng)調(diào)用。頻繁的系統(tǒng)調(diào)用是比較消耗系統(tǒng)資源的。
因此,既然存在這樣的問題,那么自然而然我們就需要解決該問題:保留非阻塞 IO 的優(yōu)點(diǎn)的前提下,減少系統(tǒng)調(diào)用
為了解決非阻塞 IO 存在的頻繁的系統(tǒng)調(diào)用這個(gè)問題,隨著內(nèi)核的發(fā)展,出現(xiàn)了 IO 多路復(fù)用模型。那么我們就需要搞懂幾個(gè)問題:
IO 多路復(fù)用: 很多人都說,IO 多路復(fù)用是用一個(gè)線程來管理多個(gè)網(wǎng)絡(luò)連接,但本人不太認(rèn)可,因?yàn)樵诜亲枞?IO 時(shí),就已經(jīng)可以實(shí)現(xiàn)一個(gè)線程處理多個(gè)網(wǎng)絡(luò)連接了,這個(gè)是由于其非阻塞而決定的。
在此處,個(gè)人觀點(diǎn),多路復(fù)用主要復(fù)用的是通過有限次的系統(tǒng)調(diào)用來實(shí)現(xiàn)管理多個(gè)網(wǎng)絡(luò)連接。最簡單來說,我目前有 10 個(gè)連接,我可以通過一次系統(tǒng)調(diào)用將這 10 個(gè)連接都丟給內(nèi)核,讓內(nèi)核告訴我,哪些連接上面數(shù)據(jù)準(zhǔn)備好了,然后我再去讀取每個(gè)就緒的連接上的數(shù)據(jù)。因此,IO 多路復(fù)用,復(fù)用的是系統(tǒng)調(diào)用。通過有限次系統(tǒng)調(diào)用判斷海量連接是否數(shù)據(jù)準(zhǔn)備好了
無論下面的 select、poll、epoll,其都是這種思想實(shí)現(xiàn)的,不過在實(shí)現(xiàn)上,select/poll 可以看做是第一版,而 epoll 是第二版
IO 多路復(fù)用第一版,這個(gè)概念是本人想出來的,主要是方便將 select/poll 和 epoll 進(jìn)行區(qū)分
所以此處 IO 多路復(fù)用第一版,主要特指 select 和 poll 這兩個(gè)。
select 的 api
// readfds:關(guān)心讀的fd集合;writefds:關(guān)心寫的fd集合;excepttfds:異常的fd集合
int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
select 函數(shù)監(jiān)視的文件描述符分 3 類,分別是 writefds、readfds、和 exceptfds。調(diào)用后 select 函數(shù)會(huì)阻塞,直到有描述副就緒(有數(shù)據(jù) 可讀、可寫、或者有 except),或者超時(shí)(timeout 指定等待時(shí)間,如果立即返回設(shè)為 null 即可),函數(shù)返回。當(dāng) select 函數(shù)返回后,可以 通過遍歷 fdset,來找到就緒的描述符。
select 目前幾乎在所有的平臺(tái)上支持,其良好跨平臺(tái)支持也是它的一個(gè)優(yōu)點(diǎn)。select 的一 個(gè)缺點(diǎn)在于單個(gè)進(jìn)程能夠監(jiān)視的文件描述符的數(shù)量存在最大限制,在 Linux 上一般為 1024,可以通過修改宏定義甚至重新編譯內(nèi)核的方式提升這一限制,但 是這樣也會(huì)造成效率的降低。
poll 的 api
int poll (struct pollfd *fds, unsigned int nfds, int timeout);
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events to watch */
short revents; /* returned events witnessed */
};
pollfd 結(jié)構(gòu)包含了要監(jiān)視的 event 和發(fā)生的 event,不再使用 select“參數(shù)-值”傳遞的方式。同時(shí),pollfd 并沒有最大數(shù)量限制(但是數(shù)量過大后性能也是會(huì)下降)。和 select 函數(shù)一樣,poll 返回后,需要輪詢 pollfd 來獲取就緒的描述符。
從上面看,select 和 poll 都需要在返回后,通過遍歷文件描述符來獲取已經(jīng)就緒的 socket。事實(shí)上,同時(shí)連接的大量客戶端在一時(shí)刻可能只有很少的處于就緒狀態(tài),因此隨著監(jiān)視的描述符數(shù)量的增長,其效率也會(huì)線性下降。
從本質(zhì)來說:IO 多路復(fù)用中,select()/poll()/epoll_wait()這幾個(gè)函數(shù)對應(yīng)第一階段;read()/recvfrom()對應(yīng)第二階段
IO 多路復(fù)用,主要在于復(fù)用,通過 select()或者 poll()將多個(gè) socket fds 批量通過系統(tǒng)調(diào)用傳遞給內(nèi)核,由內(nèi)核進(jìn)行循環(huán)遍歷判斷哪些 fd 上數(shù)據(jù)就緒了,然后將就緒的 readyfds 返回給用戶。再由用戶進(jìn)行挨個(gè)遍歷就緒好的 fd,讀取或者寫入數(shù)據(jù)。
所以通過 IO 多路復(fù)用+非阻塞 IO,一方面降低了系統(tǒng)調(diào)用次數(shù),另一方面可以用極少的線程來處理多個(gè)網(wǎng)絡(luò)連接。
雖然第一版 IO 多路復(fù)用解決了之前提到的頻繁的系統(tǒng)調(diào)用次數(shù),但同時(shí)引入了新的問題:用戶需要每次將海量的 socket fds 集合從用戶態(tài)傳遞到內(nèi)核態(tài),讓內(nèi)核態(tài)去檢測哪些網(wǎng)絡(luò)連接數(shù)據(jù)就緒了
但這個(gè)地方會(huì)出現(xiàn)頻繁的將海量 fd 集合從用戶態(tài)傳遞到內(nèi)核態(tài),再從內(nèi)核態(tài)拷貝到用戶態(tài)。所以,這個(gè)地方開銷也挺大。
既然還有這個(gè)問題,那我們繼續(xù)開始解決這個(gè)問題,因此就引出了第二版的 IO 多路復(fù)用。
其實(shí)思路也挺簡單,既然需要拷貝,那就想辦法,不拷貝。既然不拷貝,那就在內(nèi)核開辟一段區(qū)域咯
select 和 poll 的區(qū)別
IO 多路復(fù)用第二版主要指 epoll,epoll 的出現(xiàn)也是隨著內(nèi)核版本迭代才誕生的,在網(wǎng)上到處看到,epoll 是內(nèi)核 2.6 以后開始支持的
epoll 的出現(xiàn)是為了解決前面提到的 IO 多路復(fù)用第一版的問題
epoll 提供的 api
//創(chuàng)建epollFd,底層是在內(nèi)核態(tài)分配一段區(qū)域,底層數(shù)據(jù)結(jié)構(gòu)紅黑樹+雙向鏈表
int epoll_create(int size);//創(chuàng)建一個(gè)epoll的句柄,size用來告訴內(nèi)核這個(gè)監(jiān)聽的數(shù)目一共有多大
//往紅黑樹中增加、刪除、更新管理的socket fd
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
//這個(gè)api是用來在第一階段阻塞,等待就緒的fd。
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
1. int epoll_create(int size);
創(chuàng)建一個(gè)epoll的句柄,size用來告訴內(nèi)核這個(gè)監(jiān)聽的數(shù)目一共有多大,這個(gè)參數(shù)不同于select()中的第一個(gè)參數(shù),給出最大監(jiān)聽的fd+1的值,參數(shù)size并不是限制了epoll所能監(jiān)聽的描述符最大個(gè)數(shù),只是對內(nèi)核初始分配內(nèi)部數(shù)據(jù)結(jié)構(gòu)的一個(gè)建議。
當(dāng)創(chuàng)建好epoll句柄后,它就會(huì)占用一個(gè)fd值,在linux下如果查看/proc/進(jìn)程id/fd/,是能夠看到這個(gè)fd的,所以在使用完epoll后,必須調(diào)用close()關(guān)閉,否則可能導(dǎo)致fd被耗盡。
2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
函數(shù)是對指定描述符fd執(zhí)行op操作。
- epfd:是epoll_create()的返回值。
- op:表示op操作,用三個(gè)宏來表示:添加EPOLL_CTL_ADD,刪除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分別添加、刪除和修改對fd的監(jiān)聽事件。
- fd:是需要監(jiān)聽的fd(文件描述符)
- epoll_event:是告訴內(nèi)核需要監(jiān)聽什么事,struct epoll_event結(jié)構(gòu)如下:
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
//events可以是以下幾個(gè)宏的集合:
EPOLLIN :表示對應(yīng)的文件描述符可以讀(包括對端SOCKET正常關(guān)閉);
EPOLLOUT:表示對應(yīng)的文件描述符可以寫;
EPOLLPRI:表示對應(yīng)的文件描述符有緊急的數(shù)據(jù)可讀(這里應(yīng)該表示有帶外數(shù)據(jù)到來);
EPOLLERR:表示對應(yīng)的文件描述符發(fā)生錯(cuò)誤;
EPOLLHUP:表示對應(yīng)的文件描述符被掛斷;
EPOLLET: 將EPOLL設(shè)為邊緣觸發(fā)(Edge Triggered)模式,這是相對于水平觸發(fā)(Level Triggered)來說的。
EPOLLONESHOT:只監(jiān)聽一次事件,當(dāng)監(jiān)聽完這次事件之后,如果還需要繼續(xù)監(jiān)聽這個(gè)socket的話,需要再次把這個(gè)socket加入到EPOLL隊(duì)列里
3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
等待epfd上的io事件,最多返回maxevents個(gè)事件。
參數(shù)events用來從內(nèi)核得到事件的集合,maxevents告之內(nèi)核這個(gè)events有多大,這個(gè)maxevents的值不能大于創(chuàng)建epoll_create()時(shí)的size,參數(shù)timeout是超時(shí)時(shí)間(毫秒,0會(huì)立即返回,-1將不確定,也有說法說是永久阻塞)。該函數(shù)返回需要處理的事件數(shù)目,如返回0表示已超時(shí)。
二 工作模式
epoll 對文件描述符的操作有兩種模式:LT(level trigger)和 ET(edge trigger)。LT 模式是默認(rèn)模式,LT 模式與 ET 模式的區(qū)別如下: LT 模式:當(dāng) epoll_wait 檢測到描述符事件發(fā)生并將此事件通知應(yīng)用程序,應(yīng)用程序可以不立即處理該事件。下次調(diào)用 epoll_wait 時(shí),會(huì)再次響應(yīng)應(yīng)用程序并通知此事件。 ET 模式:當(dāng) epoll_wait 檢測到描述符事件發(fā)生并將此事件通知應(yīng)用程序,應(yīng)用程序必須立即處理該事件。如果不處理,下次調(diào)用 epoll_wait 時(shí),不會(huì)再次響應(yīng)應(yīng)用程序并通知此事件。
LT(level triggered)是缺省的工作方式,并且同時(shí)支持 block 和 no-block socket.在這種做法中,內(nèi)核告訴你一個(gè)文件描述符是否就緒了,然后你可以對這個(gè)就緒的 fd 進(jìn)行 IO 操作。如果你不作任何操作,內(nèi)核還是會(huì)繼續(xù)通知你的。
ET(edge-triggered)是高速工作方式,只支持 no-block socket。在這種模式下,當(dāng)描述符從未就緒變?yōu)榫途w時(shí),內(nèi)核通過 epoll 告訴你。然后它會(huì)假設(shè)你知道文件描述符已經(jīng)就緒,并且不會(huì)再為那個(gè)文件描述符發(fā)送更多的就緒通知,直到你做了某些操作導(dǎo)致那個(gè)文件描述符不再為就緒狀態(tài)了(比如,你在發(fā)送,接收或者接收請求,或者發(fā)送接收的數(shù)據(jù)少于一定量時(shí)導(dǎo)致了一個(gè) EWOULDBLOCK 錯(cuò)誤)。但是請注意,如果一直不對這個(gè) fd 作 IO 操作(從而導(dǎo)致它再次變成未就緒),內(nèi)核不會(huì)發(fā)送更多的通知(only once)
ET 模式在很大程度上減少了 epoll 事件被重復(fù)觸發(fā)的次數(shù),因此效率要比 LT 模式高。epoll 工作在 ET 模式的時(shí)候,必須使用非阻塞套接口,以避免由于一個(gè)文件句柄的阻塞讀/阻塞寫操作把處理多個(gè)文件描述符的任務(wù)餓死。
當(dāng) epoll_wait()調(diào)用后會(huì)阻塞,然后完了當(dāng)返回時(shí),會(huì)返回了哪些 fd 的數(shù)據(jù)就緒了,用戶只需要遍歷就緒的 fd 進(jìn)行讀寫即可。
IO 多路復(fù)用第二版 epoll 的優(yōu)點(diǎn)在于:
一開始就在內(nèi)核態(tài)分配了一段空間,來存放管理的 fd,所以在每次連接建立后,交給 epoll 管理時(shí),需要將其添加到原先分配的空間中,后面再管理時(shí)就不需要頻繁的從用戶態(tài)拷貝管理的 fd 集合。通通過這種方式大大的提升了性能。
所以現(xiàn)在的 IO 多路復(fù)用主要指 epoll
個(gè)人猜想: 如何降低占用的空間
前面介紹的所有網(wǎng)絡(luò) IO 都是同步 IO,因?yàn)楫?dāng)數(shù)據(jù)在內(nèi)核態(tài)就緒時(shí),在內(nèi)核態(tài)拷貝用用戶態(tài)的過程中,仍然會(huì)有短暫時(shí)間的阻塞等待。而異步 IO 指:內(nèi)核態(tài)拷貝數(shù)據(jù)到用戶態(tài)這種方式也是交給系統(tǒng)線程來實(shí)現(xiàn),不由用戶線程完成,目前只有 windows 系統(tǒng)的 IOCP 是屬于異步 IO。
目前 reactor 模型有以下幾種實(shí)現(xiàn)方案:
1. 單 reactor 單線程模型
2. 單 reactor 多線程模型
3. multi-reactor 多線程模型
4. multi-reactor 多進(jìn)程模型
下文網(wǎng)絡(luò)模型的圖,均摘自這篇文章
此種模型,通常是只有一個(gè) epoll 對象,所有的接收客戶端連接、客戶端讀取、客戶端寫入操作都包含在一個(gè)線程內(nèi)。該種模型也有一些中間件在用,比如 redis
但在目前的單線程 Reactor 模式中,不僅 I/O 操作在該 Reactor 線程上,連非 I/O 的業(yè)務(wù)操作也在該線程上進(jìn)行處理了,這可能會(huì)大大延遲 I/O 請求的響應(yīng)。所以我們應(yīng)該將非 I/O 的業(yè)務(wù)邏輯操作從 Reactor 線程上卸載,以此來加速 Reactor 線程對 I/O 請求的響應(yīng)。
該模型主要是通過將,前面的模型進(jìn)行改造,將讀寫的業(yè)務(wù)邏輯交給具體的線程池來實(shí)現(xiàn),這樣可以顯示 reactor 線程對 IO 的響應(yīng),以此提升系統(tǒng)性能
在工作者線程池模式中,雖然非 I/O 操作交給了線程池來處理,但是所有的 I/O 操作依然由 Reactor 單線程執(zhí)行,在高負(fù)載、高并發(fā)或大數(shù)據(jù)量的應(yīng)用場景,依然較容易成為瓶頸。所以,對于 Reactor 的優(yōu)化,又產(chǎn)生出下面的多線程模式。
在這種模型中,主要分為兩個(gè)部分:mainReactor、subReactors。mainReactor 主要負(fù)責(zé)接收客戶端的連接,然后將建立的客戶端連接通過負(fù)載均衡的方式分發(fā)給 subReactors,
subReactors 來負(fù)責(zé)具體的每個(gè)連接的讀寫
對于非 IO 的操作,依然交給工作線程池去做,對邏輯進(jìn)行解耦
mainReactor 對應(yīng) Netty 中配置的 BossGroup 線程組,主要負(fù)責(zé)接受客戶端連接的建立。一般只暴露一個(gè)服務(wù)端口,BossGroup 線程組一般一個(gè)線程工作即可 subReactor 對應(yīng) Netty 中配置的 WorkerGroup 線程組,BossGroup 線程組接受并建立完客戶端的連接后,將網(wǎng)絡(luò) socket 轉(zhuǎn)交給 WorkerGroup 線程組,然后在 WorkerGroup 線程組內(nèi)選擇一個(gè)線程,進(jìn)行 I/O 的處理。WorkerGroup 線程組主要處理 I/O,一般設(shè)置 2*CPU 核數(shù)個(gè)線程
proactor 主要是通過對異步 IO 的封裝的一種模型,它需要底層操作系統(tǒng)的支持,目前只有 windows 的 IOCP 支持的比較好。詳細(xì)的介紹可以參考這篇文章
關(guān)于c++和c的上述幾個(gè)庫對比,感興趣的話大家可以自行搜索資料。