徹底搞懂 IO 底層原理

{"type":"doc","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"武俠小說裏有很多的“心法”和“招式”。計算機技術裏的“心法”和“招式”呢,我們可以簡稱爲“道”和“術”;","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"“道” ","attrs":{}},{"type":"text","text":" 最基礎的計算機理論,隱藏於表象之下,非常抽象、晦澀難懂,需要用具象化的事物加以理解;","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"“術” ","attrs":{}},{"type":"text","text":"具體的技藝,它有可能是一門語言,比如:python 出手見效快;","attrs":{}}]}],"attrs":{}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我們今天要給大家講的底層的IO就屬於“道”的範疇,看上去簡單,實則抽象。並且在它之上衍生出了語言層面用於實戰的技術,比如我們熟悉的java語言中的NIO或者像Netty這樣的框架。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":1},"content":[{"type":"text","text":"一、混亂的 IO 概念","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"IO是Input和Output的縮寫,即輸入和輸出。廣義上的圍繞計算機的輸入輸出有很多:鼠標、鍵盤、掃描儀等等。而我們今天要探討的是在計算機裏面,主要是作用在內存、網卡、硬盤等硬件設備上的輸入輸出操作。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"談起IO的模型,大多數人腦子裏肯定是一坨混亂的概念,“阻塞”、“非阻塞”,“同步”、“異步”有什麼區別?很多同學傻傻分不清,有嘗試去搜索相關資料去探究真相,結果又被淹沒在茫茫的概念之中。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這裏嘗試簡單地去解釋下爲啥會出現這種現象,其中一個很重要的原因就是大家看到的資料對概念的解釋都站在了不同的角度,有的站在了底層內核的視角,有的直接在java層面或者Netty框架層面給大家介紹API,所以給大家造成了一定程度的困擾。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"所以在開篇之前,還是要說下本文所站的視角,我們將會從底層內核的層面給大家講解下IO。因爲萬變不離其宗,只有瞭解了底層原理,不管語言層面如何花裏胡哨,我們都能以不變應萬變。","attrs":{}}]},{"type":"heading","attrs":{"align":null,"level":1},"content":[{"type":"text","text":"二、用戶空間和內核空間","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"爲了便於大家理解複雜的IO以及零拷貝相關的技術,我們還是得花點時間在回顧下操作系統相關的知識。這一節我們重點看下用戶空間和內核空間,基於此後面我們才能更好地聊聊多路複用和零拷貝。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/21/215778a93e00c1e250154d3acd83309c.webp","alt":null,"title":"","style":[{"key":"width","value":"100%"},{"key":"bordertype","value":"boxShadow"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"硬 件 層(Hardware) ","attrs":{}}]},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"包括和我們熟知的和IO相關的CPU、內存、磁盤和網卡幾個硬件;","attrs":{}}]}],"attrs":{}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"內核空間(Kernel Space) ","attrs":{}}]},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"計算機開機後首先會運行內核程序,內核程序佔用的一塊私有的空間就是內核空間,並且可支持訪問CPU所有的指令集(ring0 - ring3)以及所有的內存空間、IO及硬件設備;","attrs":{}}]}],"attrs":{}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"用戶空間(User Space)","attrs":{}}]},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"每個普通的用戶進程都有一個單獨的用戶空間,用戶空間只能訪問受限的資源(CPU的“保護模式”)也就是說用戶空間是無法直接操作像內存、網卡和磁盤等硬件的;","attrs":{}}]}],"attrs":{}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"如上所述,那我們可能會有疑問,用戶空間的進程想要去訪問或操作磁盤和網卡該怎麼辦呢?","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"爲此,操作系統在內核中開闢了一塊唯一且合法的調用入口“System Call Interface”,也就是我們常說的系統調用,系統調用爲上層用戶提供了一組能夠操作底層硬件的API。這樣一來,用戶進程就可以通過系統調用訪問到操作系統內核,進而就能夠間接地完成對底層硬件的操作。這個訪問的過程也即用戶態到內核態的切換。常見的系統調用有很多,比如:內存映射mmap()、文件操作類的open()、IO讀寫read()、write()等等。","attrs":{}}]},{"type":"heading","attrs":{"align":null,"level":1},"content":[{"type":"text","text":"三、IO模型","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"1、 BIO(Blocking IO)","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我們先看一下大家都熟悉的BIO模型的 Java 僞代碼:","attrs":{}}]},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"\nServerSocket serverSocket = new ServerSocket(8080); // step1: 創建一個ServerSocket,並監聽8080端口\nwhile(true) { // step2: 主線程進入死循環\n Socket socket = serverSocket.accept(); // step3: 線程阻塞,開啓監聽\n \n BufferedReader reader = new BufferedReader(nwe InputStreamReader(socket.getInputStream()));\n System.out.println(\"read data: \" + reader.readLine()); // step4: 數據讀取\n \n \n PrintWriter print = new PrintWriter(socket.getOutputStream(), true);\n print.println(\"write data\"); // step5: socket數據寫入\n}","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這段代碼可以簡單理解成一下幾個步驟:","attrs":{}}]},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"創建ServerSocket,並監聽8080端口;","attrs":{}}]}],"attrs":{}},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"主線程進入死循環,用來阻塞監聽客戶端的連接,socket.accept();","attrs":{}}]}],"attrs":{}},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"數據讀取,socket.read();","attrs":{}}]}],"attrs":{}},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"寫入數據,socket.write();","attrs":{}}]}],"attrs":{}}],"attrs":{}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"underline","attrs":{}},{"type":"strong","attrs":{}}],"text":"問題","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"以上三個步驟:accept(...)、read(...)、write(...)都會造成線程阻塞。上述這個代碼使用了單線程,會導致主線程會直接夯死在阻塞的地方。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"underline","attrs":{}},{"type":"strong","attrs":{}}],"text":"優化","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我們要知道一點“","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"進程的阻塞是不會消耗CPU資源的","attrs":{}},{"type":"text","text":"”,所以在多核的環境下,我們可以創建多線程,把接收到的請求拋給多線程去處理,這樣就有效地利用了計算機的多核資源。甚至爲了避免創建大量的線程處理請求,我們還可以進一步做優化,創建一個線程池,利用池化技術,對暫時處理不了的請求做一個緩衝。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"2、“C10K”問題","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"“C10K”即“client 10k”用來指代數量龐大的客戶端;","attrs":{}}]}],"attrs":{}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"BIO看上去非常的簡單,事實上採用“BIO+線程池”來處理少量的併發請求還是比較合適的,也是最優的。但是面臨數量龐大的客戶端和請求,這時候使用多線程的弊端就逐漸凸顯出來了:","attrs":{}}]},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"嚴重依賴線程,線程還是比較耗系統資源的(一個線程大約佔用1M的空間);","attrs":{}}]}],"attrs":{}},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"頻繁地創建和銷燬代價很大,因爲涉及到複雜的系統調用;","attrs":{}}]}],"attrs":{}},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"線程間上下文切換的成本很高,因爲發生線程切換前,需要保留上一個任務的狀態,以便切回來的時候,可以再次加載這個任務的狀態。如果線程數量龐大,會造成線程做上下文切換的時間甚至大於線程執行的時間,CPU負載變高;","attrs":{}}]}],"attrs":{}}],"attrs":{}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"3、NIO非阻塞模型","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"下面開始真正走向Java NIO或者Netty框架所描述的“","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"非阻塞","attrs":{}},{"type":"text","text":"”,NIO叫Non-Blocking IO或者New IO,由於BIO可能會引入的大量線程,所以可以簡單地理解NIO處理問題的方式是通過單線程或者少量線程達到處理大量客戶端請求的目的。爲了達成這個目的,首先要做的就是把阻塞的過程非阻塞化。要想做到非阻塞,那必須得要有內核的支持,同時需要對用戶空間的進程暴露系統調用函數。所以,這裏的“非阻塞”可以理解成系統調用API級別的,而真正底層的IO操作都是阻塞的,我們後面會慢慢介紹。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"事實上,內核已經對“非阻塞”做好了支持,舉個我們剛剛說的的accept()方法阻塞的例子(Tips:java中的accept方法對應的系統調用函數也叫accept),看下官方文檔對其非阻塞部分的描述。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/aa/aa7f0f467a2449669f1d75ed22b85281.webp","alt":null,"title":"","style":[{"key":"width","value":"100%"},{"key":"bordertype","value":"boxShadow"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"官方文檔對accetp()系統調用的描述是通過把\"","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"flags","attrs":{}},{"type":"text","text":"\"參數設成\"","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"SOCK_NONBLOCK","attrs":{}},{"type":"text","text":"\"就可以達到非阻塞的目的,非阻塞之後線程會一直處理輪詢調用,這時候可以通過每次返回特殊的異常碼“","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"EAGAIN","attrs":{}},{"type":"text","text":"”或\"","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"EWOULDBLOCK","attrs":{}},{"type":"text","text":"\"告訴主程序還沒有連接到達可以繼續輪詢。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"我們可以很容易想象程序非阻塞之後的一個大致過程。所以,非阻塞模式有個最大的特點就是:","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"用戶進程需要不斷去主動詢問內核數據準備好了沒有!","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"下面我們通過一段僞代碼,看下這個調用過程:","attrs":{}}]},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"\n// 循環遍歷\nwhile(1) {\n // 遍歷fd集合\n for (fdx in range(fd1, fdn)) {\n // 如果fdx有數據\n if (null != fdx.data) {\n // 進行讀取和處理\n read(fdx)&handle(fdx);\n }\n }\n}","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這種調用方式也暴露出非阻塞模式的最大的弊端,就是需要讓用戶進程不斷切換到內核態,對連接狀態或讀寫數據做輪詢。有沒有一種方式來簡化用戶空間for循環輪詢的過程呢?那就是我們下面要重點介紹的","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"IO多路複用模型","attrs":{}},{"type":"text","text":"。","attrs":{}}]},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"4、IO多路複用模型","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"非阻塞模型會讓用戶進程一直輪詢調用系統函數,頻繁地做內核態切換。想要做優化其實也比較簡單,我們假想個業務場景,A業務系統會調用B的基礎服務查詢單個用戶的信息。隨着業務的發展,A的邏輯變複雜了,需要查100個用戶的信息。很明顯,A希望B提供一個批量查詢的接口,用集合作爲入參,一次性把數據傳遞過去就省去了頻繁的系統間調用。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"多路複用實際也差不多就是這個實現思路,只不過入參這個“集合”需要你註冊/填寫感興趣的事件,讀fd、寫fd或者連接狀態的fd等,然後交給內核幫你進行處理。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"那我們就具體來看看多路複用裏面大家都可能聽過的幾個系統調用","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":" - select()、poll()、epoll()。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"4.1 ","attrs":{}},{"type":"text","marks":[{"type":"underline","attrs":{}}],"text":"select()","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"select() ","attrs":{}},{"type":"text","text":"構造函數信息如下所示:","attrs":{}}]},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"\n/**\n * select()系統調用\n *\n * 參數列表:\n * nfds - 值爲最大的文件描述符+1\n * *readfds - 用戶檢查可讀性\n * *writefds - 用戶檢查可寫性\n * *exceptfds - 用於檢查外帶數據\n * *timeout - 超時時間的結構體指針\n */\nint select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"官方文檔對","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"select()","attrs":{}},{"type":"text","text":"的描述:","attrs":{}}]},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"DESCRIPTION","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"select() and pselect() allow a program to monitor multiple file descriptors, waiting until one or more of the file descriptors become \"ready\" for some class of I/O operation (e.g.,input possible). A file descriptor is considered ready if it is possible to perform the corresponding I/O operation (e.g., read(2)) without blocking.","attrs":{}}]}],"attrs":{}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"select()允許程序監控多個fd,阻塞等待直到一個或多個fd到達\"就緒\"狀態。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"內核使用","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"select()","attrs":{}},{"type":"text","text":"爲用戶進程提供了類似批量的接口,函數本身也會一直阻塞直到有fd爲就緒狀態返回。下面我們來具體看下","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"select()","attrs":{}},{"type":"text","text":"函數實現,以便我們更好地分析它有哪些優缺點。在","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"select()","attrs":{}},{"type":"text","text":"函數的構造器裏,我們很容易看到\"","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"fd_set","attrs":{}},{"type":"text","text":"\"這個入參類型。它是用位圖算法bitmap實現的,使用了一個大小固定的數組(fd_set設置了FD_SETSIZE固定長度爲","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"1024","attrs":{}},{"type":"text","text":"),數組中的每個元素都是0和1這樣的二進制byte,0,1映射fd對應位置上是否有讀寫事件,舉例:如果fd == 5,那麼fd_set = 000001000。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"同時 ","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"fd_set ","attrs":{}},{"type":"text","text":"定義了四個宏來處理bitmap:","attrs":{}}]},{"type":"blockquote","content":[{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"FD_ZERO(&set); ","attrs":{}},{"type":"text","text":" //  初始化,清空的作用,使集合中不含任何fd","attrs":{}}]}],"attrs":{}},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"FD_SET(fd, &set);","attrs":{}},{"type":"text","text":" // 將fd加入set集合,給某個位置賦值的操作","attrs":{}}]}],"attrs":{}},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"FD_CLR(fd, &set); ","attrs":{}},{"type":"text","text":" //  將fd從set集合中清除,去掉某個位置的值","attrs":{}}]}],"attrs":{}},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"FD_ISSET(fd, &set); ","attrs":{}},{"type":"text","text":" // 校驗某位置的fd是否在集合中","attrs":{}}]}],"attrs":{}}],"attrs":{}}],"attrs":{}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"使用bitmap算法的好處非常明顯,運算效率高,佔用內存少(使用了一個byte,8bit)。我們用僞代碼和圖片來描述下用戶進程調用select()的過程:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/42/42b1483530de04358157e1f2083d7862.webp","alt":null,"title":"","style":[{"key":"width","value":"100%"},{"key":"bordertype","value":"boxShadow"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"假設fds爲{1, 2, 3, 5, 7}對應的bitmap爲\"01110101\",拋給內核空間輪詢,當有讀寫事件時重新標記同時停止阻塞,然後整體返回用戶空間。由此我們可以看到select()系統調用的弊端也是比較明顯的:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"複雜度O(n),輪詢的任務交給了內核來做,複雜度並沒有變化,數據取出後也需要輪詢哪個fd上發生了變動;","attrs":{}}]}],"attrs":{}},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"用戶態還是需要不斷切換到內核態,直到所有的fds數據讀取結束,整體開銷依然很大;","attrs":{}}]}],"attrs":{}},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"fd_set有大小的限制,目前被硬編碼成了","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"1024","attrs":{}},{"type":"text","text":";","attrs":{}}]}],"attrs":{}},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"fd_set不可重用,每次操作完都必須重置;","attrs":{}}]}],"attrs":{}}],"attrs":{}},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"4.2 ","attrs":{}},{"type":"text","marks":[{"type":"underline","attrs":{}}],"text":"poll()","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"poll() ","attrs":{}},{"type":"text","text":"構造函數信息如下所示:","attrs":{}}]},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"/**\n * poll()系統調用\n *\n * 參數列表:\n * *fds - pollfd結構體\n * nfds - 要監視的描述符的數量\n * timeout - 等待時間\n */\nint poll(struct pollfd *fds, nfds_t nfds, int *timeout);\n \n \n### pollfd的結構體\nstruct pollfd{\n int fd;// 文件描述符\n short event;// 請求的事件\n short revent;// 返回的事件\n}","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"官方文檔對","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"poll()","attrs":{}},{"type":"text","text":"的描述:","attrs":{}}]},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"DESCRIPTION","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"poll() performs a similar task to select(2): it waits for one of a set of file descriptors to become ready to perform I/O.","attrs":{}}]}],"attrs":{}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"poll() 非常像select(),它也是阻塞等待直到一個或多個fd到達\"就緒\"狀態。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"看官方文檔描述可以知道,","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"poll()","attrs":{}},{"type":"text","text":"和","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"select()","attrs":{}},{"type":"text","text":"是非常相似的,唯一的區別在於","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"poll()","attrs":{}},{"type":"text","text":"摒棄掉了位圖算法,使用自定義的結構體","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"pollfd","attrs":{}},{"type":"text","text":",在","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"pollfd","attrs":{}},{"type":"text","text":"內部封裝了fd,並通過event變量註冊感興趣的可讀可寫事件(","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"POLLIN、POLLOUT","attrs":{}},{"type":"text","text":"),最後把 ","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"pollfd","attrs":{}},{"type":"text","text":" 交給內核。當有讀寫事件觸發的時候,我們可以通過輪詢 ","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"pollfd","attrs":{}},{"type":"text","text":",判斷revent確定該fd是否發生了可讀可寫事件。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"老樣子我們用僞代碼來描述下用戶進程調用 ","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"poll()","attrs":{}},{"type":"text","text":" 的過程:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/28/289b491c76209266080a21a2d47e8705.webp","alt":null,"title":"","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"boxShadow"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"poll()","attrs":{}},{"type":"text","text":" 相對於","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"select()","attrs":{}},{"type":"text","text":",主要的優勢是使用了pollfd的結構體:","attrs":{}}]},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"沒有了bitmap大小1024的限制;","attrs":{}}]}],"attrs":{}},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"通過結構體中的revents置位;","attrs":{}}]}],"attrs":{}}],"attrs":{}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"但是用戶態到內核態切換及O(n)複雜度的問題依舊存在。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"4.3 ","attrs":{}},{"type":"text","marks":[{"type":"underline","attrs":{}}],"text":"epoll()","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"epoll()應該是目前最主流,使用範圍最廣的一組多路複用的函數調用,像我們熟知的Nginx、Redis都廣泛地使用了此種模式。接下來我們重點分析下,epoll()的實現採用了“三步走”策略,它們分別是","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"epoll_create()、epoll_ctl()、epoll_wait()。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"underline","attrs":{}}],"text":"4.3.1 epoll_create()","attrs":{}}]},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"\n/**\n * 返回專用的文件描述符\n */\nint epoll_create(int size);","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"用戶進程通過 ","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"epoll_create() ","attrs":{}},{"type":"text","text":"函數在內核空間裏面創建了一塊空間(爲了便於理解,可以想象成創建了一塊白板),並返回了描述此空間的fd。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"underline","attrs":{}}],"text":"4.3.2 epoll_ctl()","attrs":{}}]},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"\n/**\n * epoll_ctl()系統調用\n *\n * 參數列表:\n * epfd - 由epoll_create()返回的epoll專用的文件描述符\n * op - 要進行的操作例如註冊事件,可能的取值:註冊-EPOLL_CTL_ADD、修改-EPOLL_CTL_MOD、刪除-EPOLL_CTL_DEL\n * fd - 關聯的文件描述符\n * event - 指向epoll_event的指針\n */\nint epoll_ctl(int epfd, int op, int fd , struce epoll_event *event );","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"剛剛我們說通過","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"epoll_create()","attrs":{}},{"type":"text","text":"可以創建一塊具體的空間“白板”,那麼通過","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"epoll_ctl() ","attrs":{}},{"type":"text","text":"我們可以通過自定義的epoll_event結構體在這塊\"白板上\"註冊感興趣的事件了。","attrs":{}}]},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"註冊 - EPOLL_CTL_ADD","attrs":{}}]}],"attrs":{}},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"修改 - EPOLL_CTL_MOD","attrs":{}}]}],"attrs":{}},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"刪除 - EPOLL_CTL_DEL","attrs":{}}]}],"attrs":{}}],"attrs":{}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"underline","attrs":{}}],"text":"4.3.3 epoll_wait()","attrs":{}}]},{"type":"codeblock","attrs":{"lang":"java"},"content":[{"type":"text","text":"/**\n * epoll_wait()返回n個可讀可寫的fds\n *\n * 參數列表:\n * epfd - 由epoll_create()返回的epoll專用的文件描述符\n * epoll_event - 要進行的操作例如註冊事件,可能的取值:註冊-EPOLL_CTL_ADD、修改-EPOLL_CTL_MOD、刪除-EPOLL_CTL_DEL\n * maxevents - 每次能處理的事件數\n * timeout - 等待I/O事件發生的超時值;-1相當於阻塞,0相當於非阻塞。一般用-1即可\n */\nint epoll_wait(int epfd, struce epoll_event *event , int maxevents, int timeout);","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"epoll_wait()","attrs":{}},{"type":"text","text":" 會一直阻塞等待,直到硬盤、網卡等硬件設備數據準備完成後發起","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"硬中斷","attrs":{}},{"type":"text","text":",中斷CPU,CPU會立即執行數據拷貝工作,數據從磁盤緩衝傳輸到內核緩衝,同時將準備完成的fd放到就緒隊列中供用戶態進行讀取。用戶態阻塞停止,接收到","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"具體數量","attrs":{}},{"type":"text","text":"的可讀寫的fds,返回用戶態進行數據處理。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"整體過程可以通過下面的僞代碼和圖示進一步瞭解:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/af/af4673f2b7b255319bfdf11b57391d7d.webp","alt":null,"title":"","style":[{"key":"width","value":"100%"},{"key":"bordertype","value":"boxShadow"}],"href":"","fromPaste":false,"pastePass":false}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"epoll()","attrs":{}},{"type":"text","text":" 基本上完美地解決了 ","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"poll() ","attrs":{}},{"type":"text","text":"函數遺留的兩個問題:","attrs":{}}]},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"沒有了頻繁的用戶態到內核態的切換;","attrs":{}}]}],"attrs":{}},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"O(1)複雜度,返回的\"nfds\"是一個確定的可讀寫的數量,相比於之前循環n次來確認,複雜度降低了不少;","attrs":{}}]}],"attrs":{}}],"attrs":{}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":1},"content":[{"type":"text","text":"四、同步、異步","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"細心的朋友可能會發現,本篇文章一直在解釋“","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"阻塞","attrs":{}},{"type":"text","text":"”和“","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"非阻塞","attrs":{}},{"type":"text","text":"”,“","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"同步","attrs":{}},{"type":"text","text":"”、“","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"異步","attrs":{}},{"type":"text","text":"”的概念沒有涉及,其實在很多場景下同步&異步和阻塞&非阻塞基本上是一個同義詞。阻塞和非阻塞適合從系統調用API層面來看,就像我們本文介紹的select()、poll()這樣的系統調用,同步和異步更適合站在應用程序的角度來看。應用程序在同步執行代碼片段的時候結果不會立即返回,這時候底層IO操作不一定是阻塞的,也完全有可能是非阻塞。所以說:","attrs":{}}]},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"阻塞和非阻塞:","attrs":{}},{"type":"text","text":"讀寫沒有就緒或者讀寫沒有完成,函數是否要一直等待還是採用輪詢;","attrs":{}}]}],"attrs":{}},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"同步和異步:","attrs":{}},{"type":"text","text":"同步是讀寫由應用程序完成。異步是讀寫由操作系統來完成,並通過回調的機制通知應用程序。","attrs":{}}]}],"attrs":{}}],"attrs":{}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"這邊順便提兩種大家可能會經常聽到的模式:","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"Reactor和Preactor。","attrs":{}}]},{"type":"bulletedlist","content":[{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"Reactor 模式:主動模式。","attrs":{}}]}],"attrs":{}},{"type":"listitem","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"Preactor 模式:被動模式。","attrs":{}}]}],"attrs":{}}],"attrs":{}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":1},"content":[{"type":"text","text":"五、總結","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"本篇文章從底層講解了下從BIO到NIO的一個過程,着重介紹了IO多路複用的幾個系統調用select()、poll()、epoll(),分析了下各自的優劣,技術都是持續發展演進的,目前也有很多的痛點。後續會繼續給大家介紹下與此相關的“","attrs":{}},{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"零拷貝","attrs":{}},{"type":"text","text":"”技術,以及Java NIO和Netty框架。","attrs":{}}]},{"type":"horizontalrule","attrs":{}},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"vivo 官網商城開發團隊","attrs":{}}]}],"attrs":{}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}}]}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章