nio分享

沒能解決的問題:

1、Java編程時,爲啥一定要設置非阻塞的io

2、redis是否是單進程單線程。答:是的,網絡請求+事件處理都是一個線程 (rdp什麼的,fork出子線程不算)

3、epoll_wait觸發返回的條件是什麼

一、用戶態和核心態

內核態:cpu可以訪問內存的所有數據,包括外圍設備,例如硬盤,網卡,cpu也可以將自己從一個程序切換到另一個程序。

用戶態:只能受限的訪問內存,且不允許訪問外圍設備,佔用cpu的能力被剝奪,cpu資源可以被其他程序獲取。

爲什麼要有用戶態和內核態?

CPU的所有指令中,有一些指令是非常危險的,如果錯用,將導致整個系統崩潰。比如:清內存、設置時鐘等。如果所有的程序都能使用這些指令,那麼你的系統一天死機N回就不足爲奇了。所以,CPU將指令分爲特權指令和非特權指令,對於那些危險的指令,只允許操作系統及其相關模塊使用,普通的應用程序只能使用那些不會造成災難的指令。IntelCPU將特權級別分爲4個級別:RING0RING1RING2RING3

當一個任務(進程)執行系統調用而陷入內核代碼中執行時,我們就稱進程處於內核運行態(或簡稱爲內核態)。此時處理器處於特權級最高的(0級)內核代碼中執行。

  • 當進程處於內核態時,執行的內核代碼會使用當前進程的內核棧。每個進程都有自己的內核棧。
  • 當進程在執行用戶自己的代碼時,則稱其處於用戶運行態(用戶態)。即此時處理器在特權級最低的(3級)用戶代碼中運行。

當正在執行用戶程序而突然被中斷程序中斷時,此時用戶程序也可以象徵性地稱爲處於進程的內核態。Linux使用了Ring3級別運行用戶態,Ring0作爲 內核態,沒有使用Ring1Ring2Ring3狀態不能訪問Ring0的地址空間,包括代碼和數據。Linux進程的4GB地址空間,3G-4G部分大家是共享的,是內核態的地址空間,這裏存放在整個內核的代碼和所有的內核模塊,以及內核所維護的數據。用戶運行一個程序,該程序所創建的進程開始是運 行在用戶態的,如果要執行文件操作,網絡數據發送等操作,必須通過writesend等系統調用,這些系統調用會調用內核中的代碼來完成操作,這時,必 須切換到Ring0,然後進入3GB-4GB中的內核地址空間去執行這些代碼完成操作,完成後,切換回Ring3,回到用戶態。

這樣,用戶態的程序就不能 隨意操作內核地址空間,具有一定的安全保護作用

用戶態與內核態的切換

所有用戶程序都是運行在用戶態的, 但是有時候程序確實需要做一些內核態的事情, 例如從硬盤讀取數據, 或者從鍵盤獲取輸入等. 而唯一可以做這些事情的就是操作系統, 所以此時程序就需要先操作系統請求以程序的名義來執行這些操作.

這時需要一個這樣的機制: 用戶態程序切換到內核態, 但是不能控制在內核態中執行的指令

這種機制叫系統調用, 在CPU中的實現稱之爲陷阱指令(Trap Instruction)

他們的工作流程如下:

  1. 用戶態程序將一些數據值放在寄存器中, 或者使用參數創建一個堆棧(stack frame), 以此表明需要操作系統提供的服務.
  2. 用戶態程序執行陷阱指令
  3. CPU切換到內核態, 並跳到位於內存指定位置的指令, 這些指令是操作系統的一部分, 他們具有內存保護, 不可被用戶態程序訪問
  4. 這些指令稱之爲陷阱(trap)或者系統調用處理器(system call handler). 他們會讀取程序放入內存的數據參數, 並執行程序請求的服務
  5. 系統調用完成後, 操作系統會重置CPU爲用戶態並返回系統調用的結果

當一個任務(進程)執行系統調用而陷入內核代碼中執行時,我們就稱進程處於內核運行態(或簡稱爲內核態)。此時處理器處於特權級最高的(0級)內核代碼中執行。當進程處於內核態時,執行的內核代碼會使用當前進程的內核棧。每個進程都有自己的內核棧。當進程在執行用戶自己的代碼時,則稱其處於用戶運行態(用戶態)。即此時處理器在特權級最低的(3級)用戶代碼中運行。當正在執行用戶程序而突然被中斷程序中斷時,此時用戶程序也可以象徵性地稱爲處於進程的內核態。因爲中斷處理程序將使用當前進程的內核棧。這與處於內核態的進程的狀態有些類似。 

內核態與用戶態是操作系統的兩種運行級別,跟intel cpu沒有必然的聯繫, intel cpu提供Ring0-Ring3三種級別的運行模式,Ring0級別最高,Ring3最低。Linux使用了Ring3級別運行用戶態,Ring0作爲 內核態,沒有使用Ring1和Ring2。Ring3狀態不能訪問Ring0的地址空間,包括代碼和數據。Linux進程的4GB地址空間,3G-4G部 分大家是共享的,是內核態的地址空間,這裏存放在整個內核的代碼和所有的內核模塊,以及內核所維護的數據。用戶運行一個程序,該程序所創建的進程開始是運 行在用戶態的,如果要執行文件操作,網絡數據發送等操作,必須通過write,send等系統調用,這些系統調用會調用內核中的代碼來完成操作,這時,必 須切換到Ring0,然後進入3GB-4GB中的內核地址空間去執行這些代碼完成操作,完成後,切換回Ring3,回到用戶態。這樣,用戶態的程序就不能 隨意操作內核地址空間,具有一定的安全保護作用。
至於說保護模式,是說通過內存頁表操作等機制,保證進程間的地址空間不會互相沖突,一個進程的操作不會修改另一個進程的地址空間中的數據。

二、中斷

操作系統的所有功能都是由中斷驅動的,系統調用和外部中斷都是以中斷方式進入操作系統內部執行的,

狀態的轉換通過軟中斷進入,中斷一般有兩個屬性,一個是中斷號,一個是中斷處理程序。不同的中斷有不同的中斷號,每個中斷號都對應了一箇中斷處理程序。在內核中通過維護中斷向量表維護這一關係。當中斷到來時,cpu會暫停正在執行的代碼,根據中斷號去中斷向量表找出對應的中斷處理程序並調用。中斷處理程序執行完成後,會繼續執行之前的代碼。這裏涉及狀態保存及返回問題,不做過多描述,嵌套的調用過程如下:
我們這裏說的軟中斷通常是一條指令,使用這條指令用戶可以手動觸發某個中斷。例如在i386下,對應的指令是int,在int指令後指定對應的中斷號,如int 0x80代表調用第0x80號的中斷處理程序。
在此,我們以一個經典的xyz函數系統調用爲例進行還原以上系統調用過程

  1. 應用程序 代碼調用系統調用( xyz ),該函數是一個包裝系統調用的 庫函數 ;
  2. 庫函數 ( xyz )負責準備向內核傳遞的參數,並觸發 軟中斷 以切換到內核;
  3. CPU 被 軟中斷 打斷後,執行 中斷處理函數 ,即 系統調用處理函數 ( system_call);
  4. 系統調用處理函數 調用 系統調用服務例程 ( sys_xyz ),真正開始處理該系統調用;
    總結下來就是用戶執行帶有中斷指令的程序時,執行到中斷調用指令int 0x80會跳轉到中斷處理函數,這也就是系統中斷調用的接入口,通過這個介入口獲取到進入內核態所需的資源,當現場保存完成、返回地址保存完成後cpu進入到內核態,並從system_call處開始指令執行(同時sys_call_table也就是上面說到的系統調用表),返回用戶態時類似,具體函數調用過程如下:
  5. start_kernel
  6. trap_init
  7. idt_setup_traps

三、Linux中的I/O

在linux環境下,任何事物都以文件的形式存在,通過文件不僅僅可以訪問常規數據,還可以訪問網絡連接和硬件。所以如傳輸控制協議 (TCP) 和用戶數據報協議 (UDP) 套接字等,系統在後臺都爲該應用程序分配了一個文件描述符(fd),無論這個文件的本質如何,該文件描述符爲應用程序與基礎操作系統之間的交互提供了通用接口。因爲應用程序打開文件的描述符列表提供了大量關於這個應用程序本身的信息,因此通過lsof工具能夠查看這個列表對系統監測以及排錯將是很有幫助的。

網絡IO的本質是socket的讀取,socket在linux系統被抽象爲流,IO可以理解爲對流的操作。剛纔說了,對於一次IO訪問(以read舉例),數據會先被拷貝到操作系統內核的緩衝區中,然後纔會從操作系統內核的緩衝區拷貝到應用程序的地址空間。所以說,當一個read操作發生時,它會經歷兩個階段:

  1. 第一階段:等待數據準備 (Waiting for the data to be ready)(數據拷貝到系統內核的緩衝區當中)。

  2. 第二階段:將數據從內核拷貝到進程中 (Copying the data from the kernel to the process)。

對於socket流而言,

  1. 第一步:通常涉及等待網絡上的數據分組到達,然後被複制到內核的某個緩衝區。

  2. 第二步:把數據從內核緩衝區複製到應用進程緩衝區。

網絡應用需要處理的無非就是兩大類問題,網絡IO,數據計算。相對於後者,網絡IO的延遲,給應用帶來的性能瓶頸大於後者。網絡IO的模型大致有如下幾種:

  • 阻塞IO(bloking IO)

  • 非阻塞IO(non-blocking IO)

  • 多路複用IO(multiplexing IO)

  • 信號驅動式IO(signal-driven IO)

  • 異步IO(asynchronous IO)

注:由於signal driven IO在實際中並不常用,所以我這隻提及剩下的四種IO Model。

每個 IO 模型都有自己的使用模式,它們對於特定的應用程序都有自己的優點。本節將簡要對其一一進行介紹。常見的IO模型有阻塞、非阻塞、IO多路複用,異步。以一個生動形象的例子來說明這四個概念。週末我和女友去逛街,中午餓了,我們準備去喫飯。週末人多,喫飯需要排隊,我和女友有以下幾種方案。

1.同步阻塞 IO(blocking IO)

1.1場景描述

我和女友點完餐後,不知道什麼時候能做好,只好坐在餐廳裏面等,直到做好,然後喫完才離開。女友本想還和我一起逛街的,但是不知道飯能什麼時候做好,只好和我一起在餐廳等,而不能去逛街,直到喫完飯才能去逛街,中間等待做飯的時間浪費掉了。這就是典型的阻塞

1.2網絡模型

同步阻塞 IO 模型是最常用的一個模型,也是最簡單的模型。在linux中,默認情況下所有的socket都是blocking。它符合人們最常見的思考邏輯。阻塞就是進程 "被" 休息, CPU處理其它進程去了

在這個IO模型中,用戶空間的應用程序執行一個系統調用(recvform),這會導致應用程序阻塞,什麼也不幹,直到數據準備好,並且將數據從內核複製到用戶進程,最後進程再處理數據,在等待數據到處理數據的兩個階段,整個進程都被阻塞。不能處理別的網絡IO。調用應用程序處於一種不再消費 CPU 而只是簡單等待響應的狀態,因此從處理的角度來看,這是非常有效的。在調用recv()/recvfrom()函數時,發生在內核中等待數據和複製數據的過程,大致如下圖:

1.3 流程描述

當用戶進程調用了recv()/recvfrom()這個系統調用,kernel就開始了IO的第一個階段:準備數據(對於網絡IO來說,很多時候數據在一開始還沒有到達。比如,還沒有收到一個完整的UDP包。這個時候kernel就要等待足夠的數據到來)。這個過程需要等待,也就是說數據被拷貝到操作系統內核的緩衝區中是需要一個過程的。而在用戶進程這邊,整個進程會被阻塞(當然,是進程自己選擇的阻塞)。第二個階段:當kernel一直等到數據準備好了,它就會將數據從kernel中拷貝到用戶內存,然後kernel返回結果,用戶進程才解除block的狀態,重新運行起來。

所以,blocking IO的特點就是在IO執行的兩個階段都被block了。

優點:

  1. 能夠及時返回數據,無延遲;

  2. 對內核開發者來說這是省事了;

缺點:

  1. 對用戶來說處於等待就要付出性能的代價了;

2 同步非阻塞 IO(nonblocking IO)

2.1 場景描述

我女友不甘心白白在這等,又想去逛商場,又擔心飯好了。所以我們逛一會,回來詢問服務員飯好了沒有,來來回回好多次,飯都還沒喫都快累死了啦。這就是非阻塞。需要不斷的詢問,是否準備好了。

2.2 網絡模型

同步非阻塞就是 “每隔一會兒瞄一眼進度條” 的輪詢(polling)方式。在這種模型中,設備是以非阻塞的形式打開的。這意味着 IO 操作不會立即完成,read 操作可能會返回一個錯誤代碼,說明這個命令不能立即滿足(EAGAIN 或 EWOULDBLOCK)。

在網絡IO時候,非阻塞IO也會進行recvform系統調用,檢查數據是否準備好,與阻塞IO不一樣,"非阻塞將大的整片時間的阻塞分成N多的小的阻塞, 所以進程不斷地有機會 '被' CPU光顧"。

也就是說非阻塞的recvform系統調用調用之後,進程並沒有被阻塞,內核馬上返回給進程,如果數據還沒準備好,此時會返回一個error。進程在返回之後,可以乾點別的事情,然後再發起recvform系統調用。重複上面的過程,循環往復的進行recvform系統調用。這個過程通常被稱之爲輪詢。輪詢檢查內核數據,直到數據準備好,再拷貝數據到進程,進行數據處理。需要注意,拷貝數據整個過程,進程仍然是屬於阻塞的狀態

在linux下,可以通過設置socket使其變爲non-blocking。當對一個non-blocking socket執行讀操作時,流程如圖所示:


輸入圖片說明

2.3 流程描述

當用戶進程發出read操作時,如果kernel中的數據還沒有準備好,那麼它並不會block用戶進程,而是立刻返回一個error。從用戶進程角度講,它發起一個read操作後,並不需要等待,而是馬上就得到了一個結果。用戶進程判斷結果是一個error時,它就知道數據還沒有準備好,於是它可以再次發送read操作。一旦kernel中的數據準備好了,並且又再次收到了用戶進程的system call,那麼它馬上就將數據拷貝到了用戶內存,然後返回。

所以,nonblocking IO的特點是用戶進程需要不斷的主動詢問kernel數據好了沒有。

同步非阻塞方式相比同步阻塞方式:

優點:能夠在等待任務完成的時間裏幹其他活了(包括提交其他任務,也就是 “後臺” 可以有多個任務在同時執行)。

缺點:任務完成的響應延遲增大了,因爲每過一段時間纔去輪詢一次read操作,而任務可能在兩次輪詢之間的任意時間完成。這會導致整體數據吞吐量的降低。

3.IO 多路複用( IO multiplexing)

3.1 場景描述

與第二個方案差不多,餐廳安裝了電子屏幕用來顯示點餐的狀態,這樣我和女友逛街一會,回來就不用去詢問服務員了,直接看電子屏幕就可以了。這樣每個人的餐是否好了,都直接看電子屏幕就可以了,這就是典型的IO多路複用

3.2 網絡模型

由於同步非阻塞方式需要不斷主動輪詢,輪詢佔據了很大一部分過程,輪詢會消耗大量的CPU時間,而 “後臺” 可能有多個任務在同時進行,人們就想到了循環查詢多個任務的完成狀態,只要有任何一個任務完成,就去處理它。如果輪詢不是進程的用戶態,而是有人幫忙就好了。那麼這就是所謂的 “IO 多路複用”。UNIX/Linux 下的 select、poll、epoll 就是幹這個的(epoll 比 poll、select 效率高,做的事情是一樣的)。

IO多路複用有兩個特別的系統調用select、poll、epoll函數。select調用是內核級別的,select輪詢相對非阻塞的輪詢的區別在於---前者可以等待多個socket,能實現同時對多個IO端口進行監聽,當其中任何一個socket的數據準好了,就能返回進行可讀然後進程再進行recvform系統調用,將數據由內核拷貝到用戶進程,當然這個過程是阻塞的。select或poll調用之後,會阻塞進程,與blocking IO阻塞不同在於,此時的select不是等到socket數據全部到達再處理, 而是有了一部分數據就會調用用戶進程來處理。如何知道有一部分數據到達了呢?監視的事情交給了內核,內核負責數據到達的處理。也可以理解爲"非阻塞"吧

I/O複用模型會用到select、poll、epoll函數,這幾個函數也會使進程阻塞,但是和阻塞I/O所不同的的,這兩個函數可以同時阻塞多個I/O操作。而且可以同時對多個讀操作,多個寫操作的I/O函數進行檢測,直到有數據可讀或可寫時(注意不是全部數據可讀或可寫),才真正調用I/O操作函數。

對於多路複用,也就是輪詢多個socket。多路複用既然可以處理多個IO,也就帶來了新的問題,多個IO之間的順序變得不確定了,當然也可以針對不同的編號。具體流程,如下圖所示:


輸入圖片說明

3.3 流程描述

IO multiplexing就是我們說的select,poll,epoll,有些地方也稱這種IO方式爲event driven IO。select/epoll的好處就在於單個process就可以同時處理多個網絡連接的IO。它的基本原理就是select,poll,epoll這個function會不斷的輪詢所負責的所有socket,當某個socket有數據到達了,就通知用戶進程。

當用戶進程調用了select,那麼整個進程會被block,而同時,kernel會“監視”所有select負責的socket,當任何一個socket中的數據準備好了,select就會返回。這個時候用戶進程再調用read操作,將數據從kernel拷貝到用戶進程。

多路複用的特點是通過一種機制一個進程能同時等待IO文件描述符,內核監視這些文件描述符(套接字描述符),其中的任意一個進入讀就緒狀態,select, poll,epoll函數就可以返回。對於監視的方式,又可以分爲 select, poll, epoll三種方式。

上面的圖和blocking IO的圖其實並沒有太大的不同,事實上,還更差一些。因爲這裏需要使用兩個system call (select 和 recvfrom),而blocking IO只調用了一個system call (recvfrom)。但是,用select的優勢在於它可以同時處理多個connection

所以,如果處理的連接數不是很高的話,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延遲還更大。(select/epoll的優勢並不是對於單個連接能處理得更快,而是在於能處理更多的連接。)

在IO multiplexing Model中,實際中,對於每一個socket,一般都設置成爲non-blocking,但是,如上圖所示,整個用戶的process其實是一直被block的。只不過process是被select這個函數block,而不是被socket IO給block。所以IO多路複用是阻塞在select,epoll這樣的系統調用之上,而沒有阻塞在真正的I/O系統調用如recvfrom之上。

在I/O編程過程中,當需要同時處理多個客戶端接入請求時,可以利用多線程或者I/O多路複用技術進行處理。I/O多路複用技術通過把多個I/O的阻塞複用到同一個select的阻塞上,從而使得系統在單線程的情況下可以同時處理多個客戶端請求。與傳統的多線程/多進程模型比,I/O多路複用的最大優勢是系統開銷小,系統不需要創建新的額外進程或者線程,也不需要維護這些進程和線程的運行,降底了系統的維護工作量,節省了系統資源,I/O多路複用的主要應用場景如下:

服務器需要同時處理多個處於監聽狀態或者多個連接狀態的套接字。

服務器需要同時處理多種網絡協議的套接字。

瞭解了前面三種IO模式,在用戶進程進行系統調用的時候,他們在等待數據到來的時候,處理的方式不一樣,直接等待,輪詢,select或poll輪詢,兩個階段過程:

第一個階段有的阻塞,有的不阻塞,有的可以阻塞又可以不阻塞。

第二個階段都是阻塞的。

從整個IO過程來看,他們都是順序執行的,因此可以歸爲同步模型(synchronous)。都是進程主動等待且向內核檢查狀態。【此句很重要!!!】

高併發的程序一般使用同步非阻塞方式而非多線程 + 同步阻塞方式。要理解這一點,首先要扯到併發和並行的區別。比如去某部門辦事需要依次去幾個窗口,辦事大廳裏的人數就是併發數,而窗口個數就是並行度。也就是說併發數是指同時進行的任務數(如同時服務的 HTTP 請求),而並行數是可以同時工作的物理資源數量(如 CPU 核數)。通過合理調度任務的不同階段,併發數可以遠遠大於並行度,這就是區區幾個 CPU 可以支持上萬個用戶併發請求的奧祕。在這種高併發的情況下,爲每個任務(用戶請求)創建一個進程或線程的開銷非常大。而同步非阻塞方式可以把多個 IO 請求丟到後臺去,這就可以在一個進程裏服務大量的併發 IO 請求

注意:IO多路複用是同步阻塞模型還是異步阻塞模型,在此給大家分析下:

此處仍然不太清楚的,強烈建議大家在細究《聊聊同步、異步、阻塞與非阻塞》中講同步與異步的根本性區別,同步是需要主動等待消息通知,而異步則是被動接收消息通知,通過回調、通知、狀態等方式來被動獲取消息IO多路複用在阻塞到select階段時,用戶進程是主動等待並調用select函數獲取數據就緒狀態消息,並且其進程狀態爲阻塞。所以,把IO多路複用歸爲同步阻塞模式

4.信號驅動式IO(signal-driven IO)

信號驅動式I/O:首先我們允許Socket進行信號驅動IO,並安裝一個信號處理函數,進程繼續運行並不阻塞。當數據準備好時,進程會收到一個SIGIO信號,可以在信號處理函數中調用I/O操作函數處理數據。過程如下圖所示:


 

5.異步非阻塞 IO(asynchronous IO)

5.1 場景描述

女友不想逛街,又餐廳太吵了,回家好好休息一下。於是我們叫外賣,打個電話點餐,然後我和女友可以在家好好休息一下,飯好了送貨員送到家裏來。這就是典型的異步,只需要打個電話說一下,然後可以做自己的事情,飯好了就送來了。

5.2 網絡模型

相對於同步IO,異步IO不是順序執行。用戶進程進行aio_read系統調用之後,無論內核數據是否準備好,都會直接返回給用戶進程,然後用戶態進程可以去做別的事情。等到socket數據準備好了,內核直接複製數據給進程,然後從內核向進程發送通知IO兩個階段,進程都是非阻塞的

Linux提供了AIO庫函數實現異步,但是用的很少。目前有很多開源的異步IO庫,例如libevent、libev、libuv。異步過程如下圖所示:


 

5.3 流程描述

用戶進程發起aio_read操作之後,立刻就可以開始去做其它的事。而另一方面,從kernel的角度,當它受到一個asynchronous read之後,首先它會立刻返回,所以不會對用戶進程產生任何block。然後,kernel會等待數據準備完成,然後將數據拷貝到用戶內存,當這一切都完成之後,kernel會給用戶進程發送一個signal或執行一個基於線程的回調函數來完成這次 IO 處理過程,告訴它read操作完成了。

在 Linux 中,通知的方式是 “信號”:

如果這個進程正在用戶態忙着做別的事(例如在計算兩個矩陣的乘積),那就強行打斷之,調用事先註冊的信號處理函數,這個函數可以決定何時以及如何處理這個異步任務。由於信號處理函數是突然闖進來的,因此跟中斷處理程序一樣,有很多事情是不能做的,因此保險起見,一般是把事件 “登記” 一下放進隊列,然後返回該進程原來在做的事

如果這個進程正在內核態忙着做別的事,例如以同步阻塞方式讀寫磁盤,那就只好把這個通知掛起來了,等到內核態的事情忙完了,快要回到用戶態的時候,再觸發信號通知

如果這個進程現在被掛起了,例如無事可做 sleep 了,那就把這個進程喚醒,下次有 CPU 空閒的時候,就會調度到這個進程,觸發信號通知。

異步 API 說來輕巧,做來難,這主要是對 API 的實現者而言的。Linux 的異步 IO(AIO)支持是 2.6.22 才引入的,還有很多系統調用不支持異步 IO。Linux 的異步 IO 最初是爲數據庫設計的,因此通過異步 IO 的讀寫操作不會被緩存或緩衝,這就無法利用操作系統的緩存與緩衝機制

很多人把 Linux 的 O_NONBLOCK 認爲是異步方式,但事實上這是前面講的同步非阻塞方式。需要指出的是,雖然 Linux 上的 IO API 略顯粗糙,但每種編程框架都有封裝好的異步 IO 實現。操作系統少做事,把更多的自由留給用戶,正是 UNIX 的設計哲學,也是 Linux 上編程框架百花齊放的一個原因。

從前面 IO 模型的分類中,我們可以看出 AIO 的動機:

同步阻塞模型需要在 IO 操作開始時阻塞應用程序。這意味着不可能同時重疊進行處理和 IO 操作。

同步非阻塞模型允許處理和 IO 操作重疊進行,但是這需要應用程序根據重現的規則來檢查 IO 操作的狀態。

這樣就剩下異步非阻塞 IO 了,它允許處理和 IO 操作重疊進行,包括 IO 操作完成的通知。

IO多路複用除了需要阻塞之外,select 函數所提供的功能(異步阻塞 IO)與 AIO 類似。不過,它是對通知事件進行阻塞,而不是對 IO 調用進行阻塞

6.五種IO模型總結

6.1 blocking和non-blocking區別

調用blocking IO會一直block住對應的進程直到操作完成,而non-blocking IO在kernel還準備數據的情況下會立刻返回。

6.2 synchronous IO和asynchronous IO區別

在說明synchronous IO和asynchronous IO的區別之前,需要先給出兩者的定義。POSIX的定義是這樣子的:

A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;

An asynchronous I/O operation does not cause the requesting process to be blocked;

兩者的區別就在於synchronous IO做”IO operation”的時候會將process阻塞。按照這個定義,之前所述的blocking IO,non-blocking IO,IO multiplexing都屬於synchronous IO。

有人會說,non-blocking IO並沒有被block啊。這裏有個非常“狡猾”的地方,定義中所指的”IO operation”是指真實的IO操作,就是例子中的recvfrom這個system call。non-blocking IO在執行recvfrom這個system call的時候,如果kernel的數據沒有準備好,這時候不會block進程。但是,當kernel中數據準備好的時候,recvfrom會將數據從kernel拷貝到用戶內存中,這個時候進程是被block了,在這段時間內,進程是被block的。

而asynchronous IO則不一樣,當進程發起IO 操作之後,就直接返回再也不理睬了,直到kernel發送一個信號,告訴進程說IO完成。在這整個過程中,進程完全沒有被block。

各個IO Model的比較如圖所示:

通過上面的圖片,可以發現non-blocking IO和asynchronous IO的區別還是很明顯的。在non-blocking IO中,雖然進程大部分時間都不會被block,但是它仍然要求進程去主動的check,並且當數據準備完成以後,也需要進程主動的再次調用recvfrom來將數據拷貝到用戶內存。而asynchronous IO則完全不同。它就像是用戶進程將整個IO操作交給了他人(kernel)完成,然後他人做完後發信號通知。在此期間,用戶進程不需要去檢查IO操作的狀態,也不需要主動的去拷貝數據

四、select & poll & epoll

在linux 沒有實現epoll事件驅動機制之前,我們一般選擇用select或者poll等IO多路複用的方法來實現併發服務程序。在大數據、高併發、集羣等一些名詞唱得火熱之年代,select和poll的用武之地越來越有限,風頭已經被epoll佔盡。

本文便來介紹epoll的實現機制,並附帶講解一下select和poll。通過對比其不同的實現機制,真正理解爲何epoll能實現高併發。

select()和poll() IO多路複用模型

select的缺點:

  1. 單個進程能夠監視的文件描述符的數量存在最大限制,通常是1024,當然可以更改數量,但由於select採用輪詢的方式掃描文件描述符,文件描述符數量越多,性能越差;(在linux內核頭文件中,有這樣的定義:#define __FD_SETSIZE    1024)
  2. 內核 / 用戶空間內存拷貝問題,select需要複製大量的句柄數據結構,產生巨大的開銷;
  3. select返回的是含有整個句柄的數組,應用程序需要遍歷整個數組才能發現哪些句柄發生了事件;
  4. select的觸發方式是水平觸發,應用程序如果沒有完成對一個已經就緒的文件描述符進行IO操作,那麼之後每次select調用還是會將這些文件描述符通知進程。

相比select模型,poll使用鏈表保存文件描述符,因此沒有了監視文件數量的限制,但其他三個缺點依然存在。

拿select模型爲例,假設我們的服務器需要支持100萬的併發連接,則在__FD_SETSIZE 爲1024的情況下,則我們至少需要開闢1k個進程才能實現100萬的併發連接。除了進程間上下文切換的時間消耗外,從內核/用戶空間大量的無腦內存拷貝、數組輪詢等,是系統難以承受的。因此,基於select模型的服務器程序,要達到10萬級別的併發訪問,是一個很難完成的任務。

因此,該epoll上場了。

epoll IO多路複用模型實現機制

由於epoll的實現機制與select/poll機制完全不同,上面所說的 select的缺點在epoll上不復存在。

設想一下如下場景:有100萬個客戶端同時與一個服務器進程保持着TCP連接。而每一時刻,通常只有幾百上千個TCP連接是活躍的(事實上大部分場景都是這種情況)。如何實現這樣的高併發?

在select/poll時代,服務器進程每次都把這100萬個連接告訴操作系統(從用戶態複製句柄數據結構到內核態),讓操作系統內核去查詢這些套接字上是否有事件發生,輪詢完後,再將句柄數據複製到用戶態,讓服務器應用程序輪詢處理已發生的網絡事件,這一過程資源消耗較大,因此,select/poll一般只能處理幾千的併發連接。

epoll的設計和實現與select完全不同。epoll通過在Linux內核中申請一個簡易的文件系統(文件系統一般用什麼數據結構實現?B+樹)。把原先的select/poll調用分成了3個部分:

1. 調用epoll_create()建立一個epoll對象(在epoll文件系統中爲這個句柄對象分配資源)

 int epfd = epoll_create(int size);

  函數生成一個epoll專用的文件描述符。它其實是在內核申請一空間,用來存放你想關注的socket fd上是否發生以及發生了什麼事件。size指的並不是最大的後備存儲設備,而是衡量內核內部結構大小的一個提示。

2. 調用epoll_ctl向epoll對象中添加這100萬個連接的套接字

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

  epfd是epoll_create()的返回值
  op參數表示動作,用三個宏來表示:EPOLL_CTL_ADD:註冊新的fd到epfd中;EPOLL_CTL_MOD:修改已經註冊的fd的監聽事件;EPOLL_CTL_DEL:從epfd中刪除一個fd;
  fd參數是需要監聽的fd,
  event參數是告訴內核需要監聽什麼事件

3. 調用epoll_wait收集發生的事件的連接

 int epoll_wait(int epfd, struct epoll_event * events, intmaxevents, int timeout);

 

epfd:由epoll_create 生成的epoll專用的文件描述符;
epoll_event:用於回傳代處理事件的數組;
maxevents:每次能處理的事件數;
timeout:等待I/O事件發生的超時值(單位我也不太清楚);-1相當於阻塞,0相當於非阻塞。一般用-1即可返回發生事件數。

 

如此一來,要實現上面說是的場景,只需要在進程啓動時建立一個epoll對象,然後在需要的時候向這個epoll對象中添加或者刪除連接。同時,epoll_wait的效率也非常高,因爲調用epoll_wait時,並沒有一股腦的向操作系統複製這100萬個連接的句柄數據,內核也不需要去遍歷全部的連接。

下面來看看Linux內核具體的epoll機制實現思路。

當某一進程調用epoll_create方法時,Linux內核會創建一個eventpoll結構體,這個結構體中有兩個成員與epoll的使用方式密切相關。eventpoll結構體如下所示:

  1. struct eventpoll{

  2. ....

  3. /*紅黑樹的根節點,這顆樹中存儲着所有添加到epoll中的需要監控的事件*/

  4. struct rb_root rbr;

  5. /*雙鏈表中則存放着將要通過epoll_wait返回給用戶的滿足條件的事件*/

  6. struct list_head rdlist;

  7. ....

  8. };

每一個epoll對象都有一個獨立的eventpoll結構體,用於存放通過epoll_ctl方法向epoll對象中添加進來的事件。這些事件都會掛載在紅黑樹中,如此,重複添加的事件就可以通過紅黑樹而高效的識別出來(紅黑樹的插入時間效率是lgn,其中n爲樹的高度)。

而所有添加到epoll中的事件都會與設備(網卡)驅動程序建立回調關係,也就是說,當相應的事件發生時會調用這個回調方法。這個回調方法在內核中叫ep_poll_callback,它會將發生的事件添加到rdlist雙鏈表中。

在epoll中,對於每一個事件,都會建立一個epitem結構體,如下所示:

  1. struct epitem{

  2. struct rb_node rbn;//紅黑樹節點

  3. struct list_head rdllink;//雙向鏈表節點

  4. struct epoll_filefd ffd; //事件句柄信息

  5. struct eventpoll *ep; //指向其所屬的eventpoll對象

  6. struct epoll_event event; //期待發生的事件類型

  7. }

當調用epoll_wait檢查是否有事件發生時,只需要檢查eventpoll對象中的rdlist雙鏈表中是否有epitem元素即可。如果rdlist不爲空,則把發生的事件複製到用戶態,同時將事件數量返回給用戶。

epoll.jpg

epoll數據結構示意圖

從上面的講解可知:通過紅黑樹和雙鏈表數據結構,並結合回調機制,造就了epoll的高效。

OK,講解完了Epoll的機理,我們便能很容易掌握epoll的用法了。一句話描述就是:三步曲。

第一步:epoll_create()系統調用。此調用返回一個句柄,之後所有的使用都依靠這個句柄來標識。

第二步:epoll_ctl()系統調用。通過此調用向epoll對象中添加、刪除、修改感興趣的事件,返回0標識成功,返回-1表示失敗。

第三部:epoll_wait()系統調用。通過此調用收集收集在epoll監控中已經發生的事件。

五、實例

1、java中使用Nio的demo

Server端:

package com.scheduler;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectableChannel;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;

/*
 *功能:基於NIO實現的服務端
 *@author:zhangdaquan
 *@date:2018/11/23 下午2:40
 *@version:1.0
 */
public class QuanServer {
    public static void main(String[] args) throws IOException {
        //打開服務端SOCKET
 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
 serverSocketChannel.socket().bind(new InetSocketAddress("127.0.0.1", 8000));
 //設置非阻塞
 serverSocketChannel.configureBlocking(Boolean.FALSE);
 //綁定監聽端口號
 Selector selector = Selector.open();
 //註冊感興趣的時間
 serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
 while(true){
               //如果沒有查到感興趣的事件,則阻塞
 selector.select();
 Iterator<SelectionKey> selectionKeyIterator = selector.selectedKeys().iterator();
 //迭代Selection中的事件
 while (selectionKeyIterator.hasNext()){
                SelectionKey key = selectionKeyIterator.next();
 selectionKeyIterator.remove();
 if(key.isAcceptable()){
                    handleAcceptable(serverSocketChannel,selector,key);
 }
                if(key.isReadable()){
                    handleRead(key);
 }
                if(key.isWritable()){
                    handleWrite(key);
 }
            }
        }
    }
    /*
 * 功能:處理read的數據
 * @author zhangdaquan
 * @date 2018/11/23 下午3:12
 * @param [readKey]
 * @return void
 * @exception
 */
 static void handleRead(SelectionKey readKey) throws IOException {
        SocketChannel channel = (SocketChannel) readKey.channel();
 ByteBuffer buffer = ByteBuffer.allocate(1<<5);
 channel.read(buffer);
 System.out.println("我收到了:"+(new String(buffer.array())));
 readKey.interestOps(SelectionKey.OP_WRITE);
 }
    /*
 * 功能:處理寫回的數據
 * @author zhangdaquan
 * @date 2018/11/23 下午3:13
 * @param [writeKey]
 * @return void
 * @exception
 */
 static void handleWrite(SelectionKey writeKey) throws IOException {
        byte [] bytes = "我寫回了:".getBytes();
 SocketChannel channel = (SocketChannel)writeKey.channel();
 ByteBuffer buffer = ByteBuffer.allocate(1<<5);
 buffer.put(bytes);
 channel.write(buffer);
 }
    /*
 * 功能:接受請求
 * @author zhangdaquan
 * @date 2018/11/23 下午3:26
 * @param [serverSocketChannel, selector, acceptableKey]
 * @return void
 * @exception
 */
 static void handleAcceptable(ServerSocketChannel serverSocketChannel, Selector selector, SelectionKey acceptableKey)
            throws IOException {
        SelectableChannel channel = serverSocketChannel.accept();
 //設置信道爲非阻塞
 channel.configureBlocking(Boolean.FALSE);
 //把選擇器註冊到連接到的客戶端信道並設置感興趣的事件
 channel.register(selector,SelectionKey.OP_READ);
 }
}

六、零拷貝

1.mmap 文件映射

  通常情況下,我們可以使用read()write()去訪問文件,除此之外,Linux 還提供了mmap()系統調用,它可以將文件映射到進程的地址空間,這樣程序就可以通過訪問內存的方式去訪問文件了。那麼與read()write()相比,使用mmap()去訪問文件能帶來什麼好處呢?
  使用mmap()一個明顯的好處就是減少一次 I/O 拷貝,譬如說,當我們使用read()讀取文件時,通常的做法是這樣:


 

1

2


 

char buffer[SIZE];

ssize_t n = read(fd, buffer, SIZE);

 

  這個過程實際上發生了兩次 I/O 拷貝,第一次是將磁盤中的文件內容拷貝到 OS 的文件系統緩衝區,第二次是將 OS 緩衝區的數據拷貝到用戶緩衝區。而使用mmap()讀取文件時,只會發生第一次拷貝操作,也就是將文件內容拷貝到 OS 文件系統緩衝區,完成這個拷貝操作之後,mmap()還會執行其它一些複雜的操作,例如將相應的 OS 緩衝區映射到進程的地址空間。
  儘管mmap()可以減少一次 I/O 拷貝,但由於mmap()的實現很複雜,調用mmap()將會帶來額外的開銷,因此在一些情況下,沒有使用mmap()的必要:

  • 訪問小文件時,直接使用read()write()將更加高效。
  • 單個進程對文件執行順序訪問時(sequential access),使用mmap()幾乎不會帶來性能上的提升。譬如說,使用read()順序讀取文件時,文件系統會使用 read-ahead 的方式提前將文件內容緩存到文件系統的緩衝區,因此使用read()將很大程度上可以命中緩存。

  那麼,在什麼情況下使用mmap()去訪問文件會更高效呢?

  • 對文件執行隨機訪問時,如果使用read()write(),則意味着較低的 cache 命中率。這種情況下使用mmap()通常將更高效。
  • 多個進程同時訪問同一個文件時(無論是順序訪問還是隨機訪問),如果使用mmap(),那麼 OS 緩衝區的文件內容可以在多個進程之間共享,從操作系統角度來看,使用mmap()可以大大節省內存。

2.sendfile()

  Web Server 處理靜態頁面請求時,通常是從磁盤中讀取網頁的內容,然後發送給客戶端:


 

1

2


 

read(fd, buffer, len);

write(sockfd, buffer, len);

 

 


  正如我們前面說到的,使用read()讀取文件時,將發生兩次 I/O 拷貝。然而,數據發送的過程也發生了兩次 I/O 拷貝,第一次是write()將用戶緩衝區的數據寫入內核的 socket 發送緩衝區,成功寫入之後write()會返回,在write()返回之後,內核會將 socket 發送緩衝區的數據拷貝到網卡驅動。可以看到,整個過程發生了四次 I/O 拷貝操作。
  然而除了考慮 I/O 拷貝帶來的開銷,我們還要考慮系統 context switch 帶來的開銷,當程序調用read()時,系統會從用戶態切換到內核態,而當read()返回時,又會導致系統從內核態切換到用戶態,所以調用read()發生兩次 context switch,同理,調用write()也會發生兩次 context switch。
  Linux 提供了sendfile()用來減少我們前面提到的 I/O 拷貝和 context switch 的次數:


 

1


 

sendfile(sockfd, fd, NULL, len);

 


  使用sendfile()發送文件時,實際發生了三次 I/O 拷貝,第一次是將磁盤中的文件內容拷貝到 OS 的文件系統緩衝區,第二次是將 OS 緩衝區的數據拷貝到 socket 的發送緩衝區,最後一次是將 socket 發送緩衝區的數據發送到網卡驅動。可以看到,與使用read()write()發送文件相比,使用sendfile()減少了一次 I/O 拷貝和兩次 context switch。
  如果使用的網卡支持 scatter-gather 特性,那麼還可以再減少一次 I/O 拷貝:

 

 


  這種情況下,使用sendfile()發送文件只會發生兩次 I/O 拷貝,第一次是將磁盤中的文件拷貝到 OS 的文件系統緩衝區,而第二次是將 OS 緩衝區的數據直接拷貝到網卡驅動。可以使用下面的命令查看網卡是否支持 scatter-gather 特性:


 

1

2

3

4


 

$ ethtool -k eth0 | grep scatter-gather

scatter-gather: on

tx-scatter-gather: on

tx-scatter-gather-fraglist: off [fixed]

 

參考資料

七、Reactor模型的應用

1.netty

2.tomcat

3.redis

4.nginx

 

我們看看nginx是如何處理一個連接的。首先,nginx在啓動時,會解析配置文件,得到需要監聽的端口與ip地址,然後在nginx的master進程裏面,先初始化好這個監控的socket(創建socket,設置addrreuse等選項,綁定到指定的ip地址端口,再listen),然後再fork出多個子進程出來,然後子進程會競爭accept新的連接。此時,客戶端就可以向nginx發起連接了。當客戶端與服務端通過三次握手建立好一個連接後,nginx的某一個子進程會accept成功,得到這個建立好的連接的socket,然後創建nginx對連接的封裝,即ngx_connection_t結構體。接着,設置讀寫事件處理函數並添加讀寫事件來與客戶端進行數據的交換。最後,nginx或客戶端來主動關掉連接,到此,一個連接就壽終正寢了。

IO密集型場景下,由於阻塞IO會讓出CPU,而nginx的Worker又是單線程(在做IO,其他啥也幹不了),導致CPU利用率降低。
這時增加進程的話,和Apache的線程模型一樣,會增加無謂的上下文切換。
同步非阻塞,立即返回EAGAIN,告知沒準備好數據,可以做其他事情,但是需要去輪詢。
異步非阻塞,可以同時監控多個事件,調用時是帶超時時間的阻塞,準備好了就返回,沒準備好放到epoll中等待。這裏Worker還是單線程,能處理的請求只有一個,但是會在請求間做切換,這個切換沒有開銷,因爲是異步事件沒有準備好,自動讓出的。

總結:

無論是Reactor模型還是Proactor模型,對於支持多連接的服務器,一般可以總結爲2種fd和3種事件,如下圖:

2種fd

  1. listenfd:一般情況,只有一個。用來監聽一個特定的端口(如80)。
  2. connfd:每個連接都有一個connfd。用來收發數據。

3種事件

  1. listenfd進行accept阻塞監聽,創建一個connfd
  2. 用戶態/內核態copy數據。每個connfd對應着2個應用緩衝區:readbuf和writebuf。
  3. 處理connfd發來的數據。業務邏輯處理,準備response到writebuf。

八、Reactor模型的本質

無論是C++還是Java編寫的網絡框架,大多數都是基於Reactor模型進行設計和開發,Reactor模型基於事件驅動,特別適合處理海量的I/O事件。

Reactor模型中定義的三種角色:

  • Reactor:負責監聽和分配事件,將I/O事件分派給對應的Handler。新的事件包含連接建立就緒、讀就緒、寫就緒等。
  • Acceptor:處理客戶端新連接,並分派請求到處理器鏈中。
  • Handler:將自身與事件綁定,執行非阻塞讀/寫任務,完成channel的讀入,完成處理業務邏輯後,負責將結果寫出channel。可用資源池來管理。

Reactor處理請求的流程:

讀取操作:

  1. 應用程序註冊讀就緒事件和相關聯的事件處理器
  2. 事件分離器等待事件的發生
  3. 當發生讀就緒事件的時候,事件分離器調用第一步註冊的事件處理器

寫入操作類似於讀取操作,只不過第一步註冊的是寫就緒事件。

1.單Reactor單線程模型

Reactor線程負責多路分離套接字,accept新連接,並分派請求到handler。Redis使用單Reactor單進程的模型。

消息處理流程:

  1. Reactor對象通過select監控連接事件,收到事件後通過dispatch進行轉發。
  2. 如果是連接建立的事件,則由acceptor接受連接,並創建handler處理後續事件。
  3. 如果不是建立連接事件,則Reactor會分發調用Handler來響應。
  4. handler會完成read->業務處理->send的完整業務流程。

單Reactor單線程模型只是在代碼上進行了組件的區分,但是整體操作還是單線程,不能充分利用硬件資源。handler業務處理部分沒有異步。

對於一些小容量應用場景,可以使用單Reactor單線程模型。但是對於高負載、大併發的應用場景卻不合適,主要原因如下:

  1. 即便Reactor線程的CPU負荷達到100%,也無法滿足海量消息的編碼、解碼、讀取和發送。
  2. 當Reactor線程負載過重之後,處理速度將變慢,這會導致大量客戶端連接超時,超時之後往往會進行重發,這更加重Reactor線程的負載,最終會導致大量消息積壓和處理超時,成爲系統的性能瓶頸。
  3. 一旦Reactor線程意外中斷或者進入死循環,會導致整個系統通信模塊不可用,不能接收和處理外部消息,造成節點故障。

爲了解決這些問題,演進出單Reactor多線程模型。

2.單Reactor多線程模型

該模型在事件處理器(Handler)部分採用了多線程(線程池)。

消息處理流程:

  1. Reactor對象通過Select監控客戶端請求事件,收到事件後通過dispatch進行分發。
  2. 如果是建立連接請求事件,則由acceptor通過accept處理連接請求,然後創建一個Handler對象處理連接完成後續的各種事件。
  3. 如果不是建立連接事件,則Reactor會分發調用連接對應的Handler來響應。
  4. Handler只負責響應事件,不做具體業務處理,通過Read讀取數據後,會分發給後面的Worker線程池進行業務處理。
  5. Worker線程池會分配獨立的線程完成真正的業務處理,如何將響應結果發給Handler進行處理。
  6. Handler收到響應結果後通過send將響應結果返回給Client。

相對於第一種模型來說,在處理業務邏輯,也就是獲取到IO的讀寫事件之後,交由線程池來處理,handler收到響應後通過send將響應結果返回給客戶端。這樣可以降低Reactor的性能開銷,從而更專注的做事件分發工作了,提升整個應用的吞吐。

但是這個模型存在的問題:

  1. 多線程數據共享和訪問比較複雜。如果子線程完成業務處理後,把結果傳遞給主線程Reactor進行發送,就會涉及共享數據的互斥和保護機制。
  2. Reactor承擔所有事件的監聽和響應,只在主線程中運行,可能會存在性能問題。例如併發百萬客戶端連接,或者服務端需要對客戶端握手進行安全認證,但是認證本身非常損耗性能。

爲了解決性能問題,產生了第三種主從Reactor多線程模型。

3.主從Reactor多線程模型

比起第二種模型,它是將Reactor分成兩部分:

  1. mainReactor負責監聽server socket,用來處理網絡IO連接建立操作,將建立的socketChannel指定註冊給subReactor。
  2. subReactor主要做和建立起來的socket做數據交互和事件業務處理操作。通常,subReactor個數上可與CPU個數等同。

Nginx、Swoole、Memcached和Netty都是採用這種實現。

消息處理流程:

  1. 從主線程池中隨機選擇一個Reactor線程作爲acceptor線程,用於綁定監聽端口,接收客戶端連接
  2. acceptor線程接收客戶端連接請求之後創建新的SocketChannel,將其註冊到主線程池的其它Reactor線程上,由其負責接入認證、IP黑白名單過濾、握手等操作
  3. 步驟2完成之後,業務層的鏈路正式建立,將SocketChannel從主線程池的Reactor線程的多路複用器上摘除,重新註冊到Sub線程池的線程上,並創建一個Handler用於處理各種連接事件
  4. 當有新的事件發生時,SubReactor會調用連接對應的Handler進行響應
  5. Handler通過Read讀取數據後,會分發給後面的Worker線程池進行業務處理
  6. Worker線程池會分配獨立的線程完成真正的業務處理,如何將響應結果發給Handler進行處理
  7. Handler收到響應結果後通過Send將響應結果返回給Client

總結

Reactor模型具有如下的優點:

  1. 響應快,不必爲單個同步時間所阻塞,雖然Reactor本身依然是同步的;
  2. 編程相對簡單,可以最大程度的避免複雜的多線程及同步問題,並且避免了多線程/進程的切換開銷;
  3. 可擴展性,可以方便地通過增加Reactor實例個數來充分利用CPU資源;
  4. 可複用性,Reactor模型本身與具體事件處理邏輯無關,具有很高的複用性。

 

 

參考:

https://segmentfault.com/a/1190000003063859

https://blog.csdn.net/u010285974/article/details/106619098

https://blog.csdn.net/u010285974/article/details/106615330

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章