小白也看得懂的 I/O 多路復(fù)用解析
前言
IO多路復(fù)用目前在大廠的面試中,一般在兩個(gè)地方可能會被問到,一個(gè)是在問到網(wǎng)絡(luò)這一塊的時(shí)候,另一個(gè)是在問到 Redis 這一塊的時(shí)候,因?yàn)?Redis 底層也是使用了IO多路復(fù)用,所以整體來說 IO多路復(fù)用,也算是一道比較高頻的一個(gè)面試題,所以今天跟大家來分享一下。
本文內(nèi)容有視頻版本,喜歡看視頻的同學(xué)可以直接通過下面的二維碼觀看。如果你對文章的內(nèi)容有疑惑,可以先看視頻的對應(yīng)內(nèi)容,視頻可能講的會更細(xì)一點(diǎn)。

基礎(chǔ)概念
首先我們了解下2個(gè)基礎(chǔ)概念,這2個(gè)概念在后續(xù)的文章中會反復(fù)用到。
Socket
套接字。百科:對網(wǎng)絡(luò)中不同主機(jī)上的應(yīng)用進(jìn)程之間進(jìn)行雙向通信的端點(diǎn)的抽象。
例子1:客戶端將數(shù)據(jù)通過網(wǎng)線發(fā)送到服務(wù)端,客戶端發(fā)送數(shù)據(jù)需要一個(gè)出口,服務(wù)端接收數(shù)據(jù)需要一個(gè)入口,這兩個(gè)“口子”就是 Socket。
例子2:兩個(gè)人通過電話進(jìn)行通信,兩個(gè)人都需要持有1個(gè)電話,socket 就類似于這個(gè)電話。
FD:file descriptor
文件描述符,非負(fù)整數(shù)。“一切皆文件”,linux 中的一切資源都可以通過文件的方式訪問和管理。而 FD 就類似文件的索引(符號、指針),指向某個(gè)資源,內(nèi)核(kernel)利用 FD 來訪問和管理資源。
之前在視頻中有同學(xué)問既然有 socket,為什么文章內(nèi)容全是用的 FD 來舉例,這是因?yàn)楫?dāng)我們調(diào)用內(nèi)核函數(shù)創(chuàng)建 socket 后,內(nèi)核返回給我們的是 socket 對應(yīng)的文件描述符(fd),所以我們對 socket 的操作基本都是通過 fd 來進(jìn)行。
Socket 通信
接著我們通過一張圖來看下客戶端和服務(wù)器使用 socket 進(jìn)行通信的核心流程。

圖中函數(shù)的含義如下:
socket:創(chuàng)建一個(gè)套接字
bind:將 socket 綁定到指定地址
listen:使套接字處于監(jiān)聽狀態(tài),等待客戶端連接到來
accept:接受客戶端連接
connect:客戶端發(fā)起連接
read:從 fd 對應(yīng)的 socket 中讀取數(shù)據(jù)
write:將數(shù)據(jù)寫入 fd 對應(yīng)的 socket 中
close:關(guān)閉 socket 文件描述符
核心交互流程如下:
1)服務(wù)器端通過 socket、bind、listen 對 socket 進(jìn)行初始化,最后阻塞在 accept 等待客戶端請求到來。
2)客戶端通過 socket 進(jìn)行初始化,然后使用 connect 向服務(wù)端發(fā)起連接請求。此時(shí)客戶端會和服務(wù)端進(jìn)行 TCP 三次握手,三次握手完成后,客戶端和服務(wù)端建立連接完畢,開始進(jìn)入數(shù)據(jù)傳輸過程。
3)客戶端發(fā)起 write 系統(tǒng)調(diào)用寫入數(shù)據(jù),數(shù)據(jù)從用戶空間拷貝到內(nèi)核空間 socket 緩沖區(qū),最后內(nèi)核將數(shù)據(jù)通過網(wǎng)絡(luò)發(fā)送到服務(wù)器。
4)數(shù)據(jù)經(jīng)過網(wǎng)絡(luò)傳輸?shù)竭_(dá)服務(wù)器網(wǎng)卡,接著內(nèi)核將數(shù)據(jù)拷貝到對應(yīng)的 socket 接收隊(duì)列,最后將數(shù)據(jù)從內(nèi)核空間拷貝到用戶空間。
5)客戶端和服務(wù)器完成交互后,調(diào)用 close 函數(shù)來斷開連接。
IO模型小例子
接著我們通過一個(gè)例子來了解下各種IO模型。
例子:你是一個(gè)老師,讓學(xué)生做作業(yè),學(xué)生做完作業(yè)后收作業(yè)。
同步阻塞:逐個(gè)收作業(yè),先收A,再收B,接著是C、D,如果有一個(gè)學(xué)生還未做完,則你會等到他寫完,然后才繼續(xù)收下一個(gè)。
解析:這就是同步阻塞的特點(diǎn),只要中間有一個(gè)未就緒,則你會被阻塞住,從而影響到后面的其他學(xué)生。
同步非阻塞:逐個(gè)收作業(yè),先收A,再收B,接著是C、D,如果有一個(gè)學(xué)生還未做完,則你會跳過該學(xué)生,繼續(xù)去收下一個(gè)。
解析:可以看到同步非阻塞相較于同步阻塞已經(jīng)是更好的方案了,你不會因?yàn)槟硞€(gè)學(xué)生未就緒而阻塞住,這樣就可以減少對后續(xù)學(xué)生的影響。但是這個(gè)方案也可能會出現(xiàn)其他問題,如果你下去收作業(yè)的時(shí)候,全部學(xué)生都還沒做完,則你可能會白走一圈,然后一個(gè)作業(yè)也沒收到。
select/poll:學(xué)生寫完了作業(yè)會舉手,但是你不知道是誰舉手,需要一個(gè)個(gè)的去詢問。
解析:這個(gè)方案相較于同步非阻塞來說有一點(diǎn)好處,就是你是確認(rèn)有學(xué)生做完的,所以你下去肯定能收到作業(yè),但是他有一個(gè)不好的點(diǎn)在于你需要一個(gè)個(gè)的去詢問。
epoll:學(xué)生寫完了作業(yè)會舉手,你知道是誰舉手,你直接去收作業(yè)。
解析:這個(gè)方案就很高效了,每次都能準(zhǔn)確的收到作業(yè)。
同步阻塞IO
核心流程:當(dāng)應(yīng)用程序發(fā)起 read 系統(tǒng)調(diào)用時(shí),在內(nèi)核數(shù)據(jù)沒有準(zhǔn)備好之前,應(yīng)用程序會一直處于阻塞等待狀態(tài),直到內(nèi)核把數(shù)據(jù)準(zhǔn)備好了返回給應(yīng)用程序
交互流程
我們通過兩段代碼的一個(gè)動(dòng)圖來模擬同步阻塞IO下服務(wù)端和客戶端的執(zhí)行流程:

大致流程如下:
1)服務(wù)端進(jìn)行初始化:新建 socket、綁定地址、轉(zhuǎn)為服務(wù)端 socket
2)服務(wù)端調(diào)用 accept,進(jìn)入阻塞狀態(tài),等待客戶端連接
3)客戶端新建 socket,向服務(wù)端發(fā)起連接
4)服務(wù)端和客戶端通過 TCP 三次握手建立連接
5)服務(wù)端繼續(xù)執(zhí)行 read 函數(shù),進(jìn)入阻塞狀態(tài),等待客戶端發(fā)送數(shù)據(jù)
6)客戶端向服務(wù)端發(fā)送數(shù)據(jù)
7)服務(wù)端讀取數(shù)據(jù),執(zhí)行邏輯處理
同步阻塞IO模型
我們通過 read 函數(shù)來看下服務(wù)器內(nèi)部用戶空間和內(nèi)核空間的調(diào)用流程,如下圖所示:

大致流程如下:
1)應(yīng)用進(jìn)程發(fā)起 read 系統(tǒng)調(diào)用
2)應(yīng)用進(jìn)程阻塞等待數(shù)據(jù)就緒
3)數(shù)據(jù)通過網(wǎng)絡(luò)傳輸?shù)竭_(dá)網(wǎng)卡,然后再到內(nèi)核socket緩沖區(qū),當(dāng)數(shù)據(jù)被拷貝到內(nèi)核 socket 緩沖區(qū)時(shí),此時(shí)處于就緒狀態(tài)
4)將數(shù)據(jù)從內(nèi)核拷貝到應(yīng)用程序緩沖區(qū),返回成功
多線程版本:文中使用的例子是單線程,如果是多線程則在每個(gè) socket 建立連接后新建線程去負(fù)責(zé)處理該 socket 后續(xù)的流程,這樣就不會由于單個(gè) socket 阻塞住而影響到其他 socket。
總結(jié)
單線程:某個(gè) socket 阻塞,會影響到其他 socket 處理。
多線程:當(dāng)客戶端較多時(shí),會造成資源浪費(fèi),全部 socket 中可能每個(gè)時(shí)刻只有幾個(gè)就緒。同時(shí),線程的調(diào)度、上下文切換乃至它們占用的內(nèi)存,可能都會成為瓶頸。
同步非阻塞IO
核心流程:當(dāng)應(yīng)用程序發(fā)起 read 系統(tǒng)調(diào)用時(shí),在內(nèi)核數(shù)據(jù)沒有準(zhǔn)備好之前,內(nèi)核會直接返回錯(cuò)誤,應(yīng)用程序不斷輪詢內(nèi)核,直到內(nèi)核把數(shù)據(jù)準(zhǔn)備好了返回給應(yīng)用程序。
交互流程
我們通過兩段代碼的一個(gè)動(dòng)圖來模擬同步阻塞IO下服務(wù)端和客戶端的執(zhí)行流程:

大致流程如下:
1)服務(wù)端調(diào)用 accept,數(shù)據(jù)未就緒,內(nèi)核返回-1
2)服務(wù)端調(diào)用 accept,數(shù)據(jù)未就緒,內(nèi)核返回-1
3)服務(wù)端調(diào)用 accept,數(shù)據(jù)未就緒,內(nèi)核返回-1
4)客戶端新建 socket,向服務(wù)端發(fā)起連接
4)服務(wù)端調(diào)用 accept,服務(wù)端和客戶端通過 TCP 三次握手建立連接
5)服務(wù)端執(zhí)行后續(xù)邏輯處理
我們通過 read 函數(shù)來看下服務(wù)器內(nèi)部用戶空間和內(nèi)核空間的調(diào)用流程,如下圖所示:

大致流程如下:
1)服務(wù)端調(diào)用 read,數(shù)據(jù)未就緒,內(nèi)核返回-1
2)服務(wù)端調(diào)用 read,數(shù)據(jù)未就緒,內(nèi)核返回-1
3)服務(wù)端調(diào)用?read,數(shù)據(jù)就緒
4)將數(shù)據(jù)從內(nèi)核拷貝到應(yīng)用程序緩沖區(qū),返回成功
同步非阻塞IO模型

總結(jié):提供了非阻塞調(diào)用的方式,從操作系統(tǒng)層面解決了阻塞問題。
優(yōu)點(diǎn):單個(gè) socket 阻塞,不會影響到其他 socket?
缺點(diǎn):需要不斷的遍歷進(jìn)行系統(tǒng)調(diào)用,有一定開銷
SELECT
核心流程:
1)應(yīng)用程序首先發(fā)起 select 系統(tǒng)調(diào)用,傳入要監(jiān)聽的文件描述符集合
2)內(nèi)核遍歷應(yīng)用程序傳入的?fd 集合,如果遍歷完一遍后發(fā)現(xiàn)沒有就緒的 fd 則用戶進(jìn)程會進(jìn)入阻塞狀態(tài),如果有就緒的 fd 則會對就緒的 fd 打標(biāo),然后返回
3)應(yīng)用程序遍歷 fd 集合,找到就緒的 fd,進(jìn)行相應(yīng)的事件處理
select 接口
/*** 獲取就緒事件** @param nfds 3個(gè)監(jiān)聽集合的文件描述符最大值+1* @param readfds 要監(jiān)聽的可讀文件描述符集合* @param writefds 要監(jiān)聽的可寫文件描述符集合* @param exceptfds 要監(jiān)聽的異常文件描述符集合* @param timeval 本次調(diào)用的超時(shí)時(shí)間* @return 大于0:已就緒的文件描述符數(shù);等于0:超時(shí);小于:出錯(cuò)*/int select(int nfds,fd_set *readfds,fd_set *writefds,fd_set *exceptfds,struct timeval *timeout);
交互流程
我們通過一個(gè)動(dòng)圖來模擬服務(wù)器內(nèi)部用戶空間和內(nèi)核空間的調(diào)用流程,如下圖所示:

大致流程如下:
1)用戶空間發(fā)起 select 系統(tǒng)調(diào)用,將監(jiān)聽的 fd 集合從用戶空間拷貝到內(nèi)核空間
2)內(nèi)核遍歷 fd 集合,檢查數(shù)據(jù)是否就緒
3)如果遍歷一遍后發(fā)現(xiàn)沒有 fd 就緒,則會將當(dāng)前用戶進(jìn)程阻塞,讓出 CPU 給其他進(jìn)程
4)當(dāng)客戶端將數(shù)據(jù)發(fā)送到服務(wù)端,進(jìn)入內(nèi)核后,會通過數(shù)據(jù)庫包找到對應(yīng)的socket?
PS:客戶端發(fā)送數(shù)據(jù)到數(shù)據(jù)進(jìn)入服務(wù)端內(nèi)核的流程類似下面 epoll 的流程
5)socket 檢查是否有阻塞等待的進(jìn)程,如果有則喚醒該進(jìn)程
6)用戶進(jìn)程恢復(fù)運(yùn)行后,會再遍歷 fd?集合進(jìn)行檢查,此時(shí)它會檢查到某些 fd 已經(jīng)就緒了,它會給這些 fd 打上標(biāo)記,然后結(jié)束阻塞,返回到用戶空間
7)用戶空間知道有事件就緒,遍歷 fd 集合,找到就緒的 fd,進(jìn)行相應(yīng)的事件處理,例如將數(shù)據(jù)從內(nèi)核緩沖區(qū)拷貝到應(yīng)用程序緩沖區(qū)
8)最后執(zhí)行邏輯處理。
IO多路復(fù)用模型

fd_set
fd_set 在 select 的整個(gè)調(diào)用過程中表達(dá)了兩種不同的意思。
在入?yún)r(shí),fd_set 表示應(yīng)用程序要監(jiān)聽哪些 fd;在回參時(shí),fd_set表示哪些 fd 已經(jīng)就緒了。
應(yīng)用程序傳入的 fd_set 其實(shí)是個(gè)位圖,例如我們要監(jiān)聽 fd = 1、fd = 4,則傳入 0000 0101,也就是 5。
這邊使用的 long 類型數(shù)組來實(shí)現(xiàn)位圖:1個(gè) long 可以表示64位,則16個(gè)long可以表示1024位。
當(dāng)內(nèi)核處理完畢,將就緒的 fd 返回時(shí),會將就緒的 fd 對應(yīng)的位標(biāo)記為1,然后覆蓋掉入?yún)⒌?fd_set,所以我們最終返回時(shí)的 fd_set 表示的是哪些 fd 是就緒的。
總結(jié)
將 socket 是否就緒檢查邏輯下沉到操作系統(tǒng)層面,避免大量系統(tǒng)調(diào)用。
告訴你有事件就緒,但是沒告訴你具體是哪個(gè) FD。
優(yōu)點(diǎn)
不需要每個(gè) FD 都進(jìn)行一次系統(tǒng)調(diào)用,解決了頻繁的用戶態(tài)內(nèi)核態(tài)切換問題
缺點(diǎn)
單進(jìn)程監(jiān)聽的 FD 存在限制,默認(rèn)1024
每次調(diào)用需要將 FD 從用戶態(tài)拷貝到內(nèi)核態(tài)
不知道具體是哪個(gè)文件描述符就緒,需要遍歷全部文件描述符
入?yún)⒌?個(gè) fd_set 集合每次調(diào)用都需要重置
POLL
核心流程:基本同 select。
poll 接口
/*** 獲取就緒事件** @param pollfd 要監(jiān)聽的文件描述符集合* @param nfds 文件描述符數(shù)量* @param timeout 本次調(diào)用的超時(shí)時(shí)間* @return 大于0:已就緒的文件描述符數(shù);等于0:超時(shí);小于:出錯(cuò)*/int poll(struct pollfd *fds,unsigned int nfds,int timeout);struct pollfd {int fd; // 監(jiān)聽的文件描述符short events; // 監(jiān)聽的事件short revents; // 就緒的事件}
poll 函數(shù)基本同 select,只是對 select 進(jìn)行了一些小優(yōu)化,一個(gè)是優(yōu)化了1024個(gè)文件描述符上限,另一個(gè)是新定義了 pollfd 數(shù)據(jù)結(jié)構(gòu),使用兩個(gè)不同的變量來表示監(jiān)聽的事件和就緒的事件,這樣就不需要像 select 那樣每次重置 fd_set 了。
總結(jié):跟 select 基本類似,主要優(yōu)化了監(jiān)聽1024的限制。
優(yōu)點(diǎn)
不需要每個(gè) FD 都進(jìn)行一次系統(tǒng)調(diào)用,導(dǎo)致頻繁的用戶態(tài)內(nèi)核態(tài)切換
缺點(diǎn)
每次需要將 FD 從用戶態(tài)拷貝到內(nèi)核態(tài)
不知道具體是哪個(gè)文件描述符就緒,需要遍歷全部文件描述符
EPOLL
核心流程:
1)應(yīng)用程序調(diào)用 epoll_create,內(nèi)核會分配一塊內(nèi)存空間,創(chuàng)建一個(gè) epoll,最后將 epoll 的 fd 返回,我們后續(xù)可以通過這個(gè) fd 來操作 epoll 對象
2)應(yīng)用程序不斷調(diào)用 epoll_ctl 將我們要監(jiān)聽的 fd 維護(hù)到 epoll,內(nèi)核通過紅黑樹的結(jié)構(gòu)來高效的維護(hù)我們傳入的 fd 集合
3)應(yīng)用程序調(diào)用?epoll_wait 來獲取就緒事件,內(nèi)核檢查 epoll 的就緒列表,如果就緒列表為空則會進(jìn)入阻塞,否則直接返回就緒的事件。
4)應(yīng)用程序根據(jù)內(nèi)核返回的就緒事件,進(jìn)行相應(yīng)的事件處理
epoll 接口
/*** 創(chuàng)建一個(gè)epoll** @param size epoll要監(jiān)聽的文件描述符數(shù)量* @return epoll的文件描述符*/int epoll_create(int size);/*** 事件注冊** @param epfd epoll的文件描述符,epoll_create創(chuàng)建時(shí)返回* @param op 操作類型:新增(1)、刪除(2)、更新(3)* @param fd 本次要操作的文件描述符* @param epoll_event 需要監(jiān)聽的事件:讀事件、寫事件等* @return 如果調(diào)用成功返回0, 不成功返回-1*/int epoll_ctl(int epfd,int op,int fd,struct epoll_event *event);/*** 獲取就緒事件** @param epfd epoll的文件描述符,epoll_create創(chuàng)建時(shí)返回* @param events 用于回傳就緒的事件* @param maxevents 每次能處理的最大事件數(shù)* @param timeout 等待I/O事件發(fā)生的超時(shí)時(shí)間,-1相當(dāng)于阻塞,0相當(dāng)于非阻塞* @return 大于0:已就緒的文件描述符數(shù);等于0:超時(shí);小于:出錯(cuò)*/int epoll_wait(int epfd,struct epoll_event *events,int maxevents,int timeout);
交互流程
我們通過一個(gè)動(dòng)圖來模擬服務(wù)器內(nèi)部用戶空間和內(nèi)核空間的調(diào)用流程,如下圖所示:

大致流程如下:
1)用戶空間調(diào)用?epoll_create?,內(nèi)核新建 epoll 對象,返回 epoll 的 fd,用于后續(xù)操作
2)用戶空間反復(fù)調(diào)用 epoll_ctl 將我們要監(jiān)聽的 fd 維護(hù)到 epoll,底層通過紅黑樹來高效的維護(hù) fd 集合
3)用戶空間調(diào)用 epoll_wait 獲取就緒事件,內(nèi)核檢查 epoll 的就緒列表,如果就緒列表為空則會進(jìn)入阻塞
4)客戶端向服務(wù)端發(fā)送數(shù)據(jù),數(shù)據(jù)通過網(wǎng)絡(luò)傳輸?shù)椒?wù)端的網(wǎng)卡
5)網(wǎng)卡通過?DMA 的方式將數(shù)據(jù)包寫入到指定內(nèi)存中(ring_buffer),處理完成后通過中斷信號告訴 CPU 有新的數(shù)據(jù)包到達(dá)
6)CPU 收到中斷信號后,進(jìn)行響應(yīng)中斷,首先保存當(dāng)前執(zhí)行程序的上下文環(huán)境,然后調(diào)用中斷處理程序(網(wǎng)卡驅(qū)動(dòng)程序)進(jìn)行處理:
根據(jù)數(shù)據(jù)包的ip和port找到對應(yīng)的socket,將數(shù)據(jù)放到socket的接收隊(duì)列;
執(zhí)行 socket 對應(yīng)的回調(diào)函數(shù):將當(dāng)前 socket 添加到 eventpoll 的就緒列表、喚醒 eventpool 等待隊(duì)列里的用戶進(jìn)程(設(shè)置為RUNNING狀態(tài))
7)用戶進(jìn)程恢復(fù)運(yùn)行后,檢查 eventpoll 里的就緒列表不為空,則將就緒事件填充到入?yún)⒅械?events 里,然后返回
8)用戶進(jìn)程收到返回的事件后,執(zhí)行 events 里的事件處理,例如讀事件則將數(shù)據(jù)從內(nèi)核緩沖區(qū)拷貝到應(yīng)用程序緩沖區(qū)
9)最后執(zhí)行邏輯處理。
IO多路復(fù)用模型
同 select。

總結(jié)
epoll 直接將 fd 集合維護(hù)在內(nèi)核中,通過紅黑樹來高效管理 fd 集合,同時(shí)維護(hù)一個(gè)就緒列表,當(dāng) fd 就緒后會添加到就緒列表中,當(dāng)應(yīng)用空間調(diào)用 epoll_wait 獲取就緒事件時(shí),內(nèi)核直接判斷就緒列表即可知道是否有事件就緒。
優(yōu)點(diǎn)
解決了 select 和 poll 的缺點(diǎn),高效處理高并發(fā)下的大量連接,同時(shí)有非常優(yōu)異的性能。
缺點(diǎn)
跨平臺性不夠好,只支持 linux,macOS 等操作系統(tǒng)不支持
相較于 epoll,select 更輕量可移植性更強(qiáng)
在監(jiān)聽連接數(shù)和事件較少的場景下,select 可能更優(yōu)
LT?VS ET
LT:Level-triggered,水平(條件)觸發(fā),默認(rèn)。epoll_wait 檢測到事件后,如果該事件沒被處理完畢,后續(xù)每次 epoll_wait 調(diào)用都會返回該事件。
ET:Edge-triggered,邊緣觸發(fā)。epoll_wait 檢測到事件后,只會在當(dāng)次返回該事件,不管該事件是否被處理完畢。
小結(jié)
epoll 和 select、poll 默認(rèn)都是 LT 模式,LT 模式會更安全一點(diǎn),而 ET 則是 epoll 為了性能開發(fā)的一種新模式,LT 模式下內(nèi)核在返回就緒事件之前都會進(jìn)行一次額外的判斷,如果 fd 量較大,會有一定的性能損耗。
總結(jié)
可以看到從最初的同步阻塞IO,到現(xiàn)在主流的 epoll,其實(shí)是一個(gè)不斷演進(jìn)的過程,就像我們的業(yè)務(wù)系統(tǒng)一樣。
同步阻塞IO的方式實(shí)現(xiàn)比較簡單,同時(shí)在當(dāng)時(shí)可能已經(jīng)能滿足需求了,因此被最早提出來,然后隨著不斷的發(fā)展,在一些場景下,同步阻塞IO逐漸不能滿足需求,于是操作系統(tǒng)底層開始優(yōu)化,提出了非阻塞的模式。類似的,同步非阻塞IO也存在一定的問題,于是就有了后續(xù)的IO多路復(fù)用。
現(xiàn)在還有一種更牛逼的IO模型也在發(fā)展,叫做異步IO,這種模型下,你只需要一次非阻塞的系統(tǒng)調(diào)用,后續(xù)的事情全部由內(nèi)核來幫你完成。不過異步IO當(dāng)前在 linux 下還不夠完善,所以當(dāng)前 linux 的主流還是 epoll。
推薦閱讀
全網(wǎng)最實(shí)用的 IDEA Debug 調(diào)試技巧(超詳細(xì)案例)
最近我將面試:阿里、字節(jié)、美團(tuán)、快手、拼多多等大廠的高頻面試整理出來,并按大廠的標(biāo)準(zhǔn)給出自己的解析。
群里有不少同學(xué)看完拿下了阿里、美團(tuán)等大廠 Offer,希望能助你一臂之力,早日拿下大廠 Offer。
獲取方式:關(guān)注公眾號回復(fù)【面試】即可領(lǐng)取,更多大廠面試真題解析 PDF 整理中。

