IO 多路復用(四)epoll 函數(shù)

引言

epoll 是 Linux 特有的結構,它允許一個進程監(jiān)聽多個文件描述符蚓哩,并在 I/O 就緒時獲取到通知咬展。epoll 有 ET(edge-triggered) 跟 LT(level-triggered) 兩種對文件描述符的操作模式澜躺,默認為 LT。在我們深入了解它之前淋纲,讓我們先看看它的語法劳闹。

epll 語法

與 poll 不同的是,epoll 本身并不是一個系統(tǒng)調用洽瞬。它是一個允許進程在多個文件描述符上復用 I/O 的內核數(shù)據(jù)結構本涕。

image.png

該數(shù)據(jù)結構通過以下三個系統(tǒng)調用創(chuàng)建、修改伙窃、刪除菩颖。

epoll_create

epoll_create 用于創(chuàng)建 epoll 實例,并返回一個文件描述符給 epoll 實例为障。方法簽名如下:

#include <sys/epoll.h>
int epoll_create(int size);

size 參數(shù)用來告知內核進程想要監(jiān)聽的文件描述符數(shù)量晦闰,以此來輔助內核推算出 epoll 實例的大小。但是鳍怨,從 Linux 2.6.8 起呻右,epoll 數(shù)據(jù)結構可以隨著文件描述符的添加、刪除動態(tài)調整鞋喇,所以該參數(shù)就可忽略了声滥。

epoll_create 返回一個文件描述符給新創(chuàng)建的 epll 內核數(shù)據(jù)結構。調用進程可以通過該描述進行添加侦香、刪除落塑、修改它想要監(jiān)聽 I/O 的其他描述符 給 epoll 實例。

image.png

另一個 epoll_create1 的系統(tǒng)調用簽名如下:

int epoll_create1(int flags);

flag 參數(shù)既可以是 0 也可以是 EPOLL_CLOEXEC罐韩。

當 flag 為 0 的時候芜赌,該函數(shù)的行為跟 epoll_create 一致。

當 flag 為 EPOLL_CLOEXEC 時伴逸,當前進程 fork 出來的任何子進程在執(zhí)行前都會關閉 epoll 描述符缠沈。也因此,子進程不能夠訪問 epoll 實例。

?? Tips

有一點需要特別注意洲愤,關聯(lián) epoll 實例的文件描述符需要通過 close() 系統(tǒng)調用來釋放颓芭。多個進程可能持有同一個 epoll 實例的文件描述符(如:當 EPOLL_CLOEXEC 標記沒有指定時,fork 出來的子進程會復制該文件描述符)柬赐。當所有的進程都不再使用該描述符時(通過調用 close() 或者退出)亡问,內核才會銷毀 epoll 實例。

epoll_ctl

進程可以通過 epoll_ctl 來添加它想要監(jiān)聽的描述符給 epoll 實例肛宋。所有注冊到 epoll 實例的文件描述符統(tǒng)稱為 epoll set 或者 interest list州藕。

image.png

上圖中,pid 為 483 的進程在 epoll 實例中注冊了 FD1酝陈,F(xiàn)D2床玻,F(xiàn)D3,F(xiàn)D4沉帮,F(xiàn)D5 文件描述符锈死。以上就是 epoll 實例的 interest list 或者 epoll set。隨后穆壕,當任何文件描述符已經(jīng)準備好 I/O 時待牵,它們就會放到 ready list 中。

ready list 是 interest list 的子集喇勋,如圖所示:

image.png

epoll_ctl 的簽名如下:

#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
image.png
  • epfdepoll_create 返回的 epoll 文件描述符缨该,指向內核中 epoll 實例。

  • fd 需要注冊到 epoll list 或者 interest list 中的文件描述符川背。

  • op 對以上文件描述符 fd 要執(zhí)行的操作压彭,一般支持以下三個操作:

  • EPOLL_CTL_ADD 向 epoll 實例中注冊該文件描述符,當指定事件發(fā)生時渗常,獲取到通知壮不。

  • EPOLL_CTL_DEL 將該文件描述符從 eopll 實例中刪除,意味著進程不會收到任何發(fā)生在該文件描述符上事件的通知皱碘。

  • EPOLL_CTL_MOD 修改該文件描述符上的監(jiān)聽事件询一。

    image.png

event 指向存儲想要在該文件描述符監(jiān)聽事件的 epoll_event 結構的指針。

image.png

以下是 epoll_event 結構體:

struct epoll_event
{
  uint32_t events;          /* Epoll events */
  epoll_data_t data;        /* User data variable */
}

typedef union epoll_data
{
  void *ptr;
  int fd;
  uint32_t u32;
  uint64_t u64;
} epoll_data_t;

epoll_event 結構的第一個字段 events 是一個位掩碼癌椿,表示 fd 上哪些事件正在監(jiān)聽健蕊。

比如:如果 fd 是一個 socket 描述符,我們可能想要監(jiān)聽是否有新的數(shù)據(jù)到達 socket buffer踢俄,那我們可以設置 EPOLLIN 事件缩功。如果我們想要獲取 fd 上 edge-triggered 觸發(fā)的通知,可以設置 EPOLLET 按位或 EPOLLIN等等都办。

events 可以由以下幾種宏表示:

  • EPOLLIN 表示對應的文件描述符可以讀(包括對端 socket 正常關閉)嫡锌。
  • EPOLLOUT 表示對應的文件描述符可以寫虑稼。
  • EPOLLPRI 表示對應的文件描述符有緊急的數(shù)據(jù)可。
  • EPOLLERR 表示對應的文件描述符發(fā)生錯誤势木;
  • EPOLLHUP 表示對應的文件描述符被掛斷蛛倦;
  • EPOLLET 將 EPOLL 設為邊緣觸發(fā)(Edge Triggered)模式,這是相對于水平觸發(fā)(Level Triggered)來說的啦桌。
  • EPOLLONESHOT 只監(jiān)聽一次事件溯壶,當監(jiān)聽完這次事件之后,如果還需要繼續(xù)監(jiān)聽這個 socket 的話甫男,需要再次把這個 socket 加入到 epoll 隊列里

更多的用法可以從【man page】 中獲取且改。

epoll_event 的第二個字段是一個聯(lián)合類型。

epoll_wait

通過 epoll_wait 系統(tǒng)調用通知線程 epoll set/ interest list 上事件的發(fā)生板驳,該調用會一直阻塞又跛,直到任何一個被監(jiān)聽的描述符準備好 I/O。

該函數(shù)簽名如下:

#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *evlist, int maxevents, int timeout);
  • epfd epoll_create 返回的 epoll 文件描述符笋庄。

  • evlist epoll_event 結構數(shù)組。evlist 由調用進程分配倔监,當 epoll_wait 返回時直砂,該數(shù)組存放處于 interest list 中就緒狀態(tài)(ready list)的描述符及對應的事件信息。

maxevents — evlist 數(shù)組的長度浩习。

timeout — 該參數(shù)的行為跟 poll 以及 select 一致静暂,指定 epoll_wait 調用等待時長。

  • 當 timeout 為 0 時谱秽,epoll_wait 調用在檢查 epfd 的 interest list 中哪些文件描述符準備就緒之后立即返回洽蛀,不會阻塞。
  • 當 timeout 為 -1 時疟赊,eopll_wait 會一直阻塞(此時內核會將該進程休眠郊供,直到調用返回)直到一個或者多個處于 interest list 中的描述符變成就緒狀態(tài)或者該調用被信號處理程序中斷。
  • 當 timeout 為一個正值時近哟,epoll_wait 調用會等待 timeout 毫秒數(shù)驮审,除非有描述符就緒或者被信號中斷。

epoll_wait 有以下返回值:

  • -1:當調用發(fā)生錯誤(EBADF or EINTR or EFAULT or EINVAL)吉执。
  • 0:調用超時疯淫。
  • 返回就緒的描述符的數(shù)量,即 evlist 數(shù)組的長度

epoll 中的陷阱

為了充分了解 epoll戳玫,我們需要了解文件描述符背后的工作原理是怎樣的熙掺。
每個進程維護一套它能訪問到的文件描述符表,表中每個條目都包含兩個字段:

  • flags 用來控制對描述符的操作(唯一相同的 flag 是 close-on-exec)
  • ptr 指向底層內核數(shù)據(jù)結構的指針

描述符既可以被 open咕宿、pipe币绩、socket 等系統(tǒng)調用顯示地創(chuàng)建蜡秽,也可以被父進程 fork 出來的子進程繼承,或者被 dup/dup2 這樣的系統(tǒng)調用“復制”类浪。

image.png

以下場景會釋放描述符:

  • 進程退出
  • close 系統(tǒng)調用
  • 進程調用 fork 派生出來的子進程會繼承下來所有父進程的描述符载城。如果任何在父進程調用 fork 之后,子進程 execs 之前標記為 close-on-exec 的描述符费就,子進程都不可用诉瓦,但是父進程仍然可以繼續(xù)使用這些描述符

假如上圖中進程 A 描述符 3 被標記為 close-on-exec,當進程 A fork 出進程 B 之后力细,兩者完全相同睬澡,進程 B 擁有了繼承下來的描述符 0,1眠蚂,2煞聪,3。

但是逝慧,由于描述符 3 被標記為 close-on-exec昔脯,所以在進程 B execs 之前,該描述符會被標記為“inactive”笛臣,進程 B 也就不能夠再訪問它了云稚。


image.png

為了弄清它,我們需要知道描述符只是一個指向被稱為文件描述符的底層內核數(shù)據(jù)結構的進程指針沈堡。
內核維護一個包含所有打開的文件描述符表叫做打開文件表(open file table)静陈。


image.png

我們假設進程 A 的描述符 fd3 是由 fd0 通過 dup 或者 fcntl 的系統(tǒng)調用創(chuàng)建的。原始的描述符 fd0 以及“復制”出來的描述符 fd3 都指向了內核中同一個文件描述符诞丽。

如果進程 A 接著 fork 出進程 B 而 fd3 被標記了 close-on-exec鲸拥,那么子進程 B 繼承下 A 的所有描述符中,fd3 是不能使用的僧免。

有一點需要特別注意的是刑赶,子進程中的 fd0 也指向了內核打開文件表中同一個打開的文件描述符。

image.png

現(xiàn)在有三個描述符懂衩,分別是進程 A 中的 fd0 和 fd3角撞,以及進程 B 中的 fd0,它們指向了底層同一個文件描述符勃痴。為了簡單化谒所,我們忽略掉進程 A 跟進程 B 的其他所有描述符(它們也都分別指向了打開文件表中的一個條目)。

?? Tips

注意沛申,文件描述符會在進程以及 fork 出的子進程中共享劣领。如果一個進程通過 Unix Domain Socke 套接字將文件描述符傳遞給另一個進程,那么兩個進程同樣會指向底層同一個內核打開的文件描述符铁材。

最后尖淘,我們需要了解另一個比較重要的概念— 文件描述符中的結點指針奕锌。
結點就是一種文件系統(tǒng)數(shù)據(jù)結構,它包含有關文件系統(tǒng)對象(如:文件村生、路徑)的相關信息惊暴,比如:

  • 文件或者路徑數(shù)據(jù)在磁盤上存儲的位置
  • 文件或路徑屬性
  • 訪問時間、所有者趁桃、權限等關于文件或者路徑的元數(shù)據(jù)信息

文件系統(tǒng)中的每個文件或者路徑都有一個結點條目辽话,該條目代表著文件的編號(也叫結點編號),在很多文件系統(tǒng)上卫病,可分配的結點數(shù)是有上限的油啤。

在磁盤上有一個用于維護結點編號到磁盤上實際結點數(shù)據(jù)結構映射的結點表赏参。為了獲取文件位置或者有關文件的元信息真仲,大部分文件系統(tǒng)使用內核提供的文件驅動通過結點編號來訪問它。

假如進程 A fork 出子進程 B 之后婉刀,進程 A 又創(chuàng)建了 fd4 跟 fd5(這兩個描述符不會被 B 繼承)帜平。

假如 fd5 是進程 A 為了讀取 abc.txt 文件通過調用 open 來創(chuàng)建的幽告,而進程 B 為了向 abc.txt 文件中寫數(shù)據(jù)通過 open 調用返回的描述符是 fd10。
那么進程 A 的 fd5 以及進程 B 的 fd10 指向了打開文件表中不同的文件描述符裆甩,但是它們指向的卻是同一個節(jié)點表中的條目(也就是說冗锁,它們指向了同一個文件)。

image.png

有兩點值得注意的地方:

  • 由于進程 A 以及進程 B 中的 fd0 指向了同一個文件描述符淑掌,那么它們就會共享文件偏移量蒿讥,也就是說蝶念,如果進程 A 通過調用 open()抛腕、write() 或者 lseek() 等向前移動了偏移量,那么進程 B 中的偏移量同樣會發(fā)生變化媒殉。對于進程 A 的 fd3 也同樣如此担敌,因為 fd3 也指向了與 fd0 一樣的文件描述符。

  • 以上規(guī)則對于其中一個進程修改了文件狀態(tài)標記(O_ASYNC, O_NONBLOCK, O_APPEND)同樣適用廷蓉。所以全封,如果進程 B 通過 fd0 使用 fcntl 系統(tǒng)調用將文件描述符改為非阻塞模式,那么進程 A 中的 fd0 跟 fd3 也同樣變?yōu)榉亲枞J健?/p>

剖析 epoll

假如進程 A 有兩個指向不同結點的文件描述符 fd0 跟 fd1桃犬。

image.png

epoll_create 在內核中創(chuàng)建一個新的結點條目(epoll 實例)以及一個新的打開文件描述符刹悴,并把指向描述符的 fd9 返回給調用者。

image.png

當我們通過 epoll_ctl 把 fd0 添加到 epoll 實例的 interest list 中是攒暇,實際上是把 fd0 對應的底層文件描述符(打開文件描述符)放到了 epoll 實例的 interest list 中土匀。

image.png

因此,epoll 實例實際上真正監(jiān)控的是底層的文件描述符形用,并不是每個進程的文件描述符就轧,這里有個有趣的現(xiàn)象:

如果進程 A fork 出進程 B证杭,B 就擁有了與 A 一樣的描述符,也包括 fd9妒御。不僅如此解愤,進程 B 的 epoll 描述符 fd9 也跟進程 A 一樣,擁有相同的 interest list乎莉。

如果進程 A 在 fork 之后送讲,創(chuàng)建了一個新的描述符 fd8 (并不會被進程 B 擁有),并通過 epoll_ctl 添加到 interest list 中梦鉴。當 fd8 上的事件發(fā)生時李茫,不僅僅是進程 A 會收到相關的通知,進程 B 也會收到肥橙。當通過調用 dup/dup2 復制 epoll 描述符或者通過 Unix Domain Socket 將 epoll 描述符從一個進程傳遞到另一個進程時魄宏,該現(xiàn)象同樣會發(fā)生。

image.png

如果進程 B 通過 open 打開一個 df8 指向的文件存筏,其描述符為 fd15宠互,然后進程 A 關閉 fd8。你可能覺得既然 fd8 已經(jīng)被關閉椭坚,那么肯定不會再收到 fd8 上相關的事件了予跌。但是,實際上并非如此善茎,因為 interest list 還在監(jiān)聽著打開的文件描述符券册。由于 fd15 跟 fd8 指向了相同的描述符,進程 A 得到的通知其實是關于 fd15 的垂涯∷副海肯定的是,如果一個進程將它關注的描述符關閉之后還陸續(xù)收到該描述符相關的事件耕赘,那么該文件描述符對應的底層描述符肯定還被其他至少一個屬于該進程或者來自于其他進程的描述引用骄蝇。

為什么 epoll 性能強于 select 跟 poll

select/poll 時間復雜度為 O(N),如果 N 比較大操骡,那么每次 select/poll 被調用時即使就緒的描述符很少九火,內核也需要掃描集合中的每個描述符。

epoll 監(jiān)聽的是底層的文件描述符册招,每當文件描述符 IO 就緒時岔激,內核就會把它們添加到 ready list 中,此過程無需進程調用 epoll_wait 來實現(xiàn)是掰。當進程調用 epoll_wait 等待事件發(fā)生時虑鼎,內核除了將它維護的 ready list 返回給調用方外,無需做任何其他額外的工作冀惭。

此外震叙,每次調用 select/poll 時掀鹅,都需要將進程想要監(jiān)聽的描述符信息傳遞給內核。而內核返回描述符信息時媒楼,進程都需要再一次掃描所有的描述符來檢查哪個描述符已經(jīng)就緒了乐尊。

對于 epoll,只要我們通過 epoll_ctl 將我們想要監(jiān)聽的描述符添加到 epoll 實例的 interest list 中划址,我們就不需要將來調用 epoll_wait 時扔嵌,向內核傳遞我們想要監(jiān)聽就緒信息的描述符了(select/poll 需要每次調用時傳遞)。

?? Tips

epoll 時間復雜度是 O(就緒的描述符數(shù)量) 并非 O(所要監(jiān)聽的描述符數(shù)量)夺颤。

邊緣觸發(fā)模式 ET

默認情況下痢缎,epoll 提供了水平觸發(fā)通知模式。每個 epoll_wait 調用只是返回 interest list 中就緒的描述符世澜。

假如我們注冊了 fd1独旷、fd2、fd3寥裂、fd4 四個描述符嵌洼,在我們調用 epoll_wait 時,只有 fd2封恰、fd3 已經(jīng)就緒了麻养,那么返回的也就只有這兩個描述符信息。

image.png

值得注意的是诺舔,在默認的水平觸發(fā)模式下鳖昌,由于 epoll 只是在底層描述符就緒時才會更新 ready list,所以 interest list 中的描述符性質(阻塞與非阻塞)并不會影響 epoll_wait 調用的結果低飒。

有時我們只是想查看 interest list 中任何一個描述符的狀態(tài)许昨,不管它是否已經(jīng)就緒。邊緣觸發(fā)模式允許我們查看任何特定的描述符(即時在調用 epoll_wait 時還沒有就緒)是否 I/O 可用逸嘀。如果我們想要知道自從上次調用 epoll_wait 以來车要,文件描述符上是否有任何 I/O 活動發(fā)生或者描述符就緒時允粤,epoll_wait 并沒有調用崭倘,我們可以調用 epoll_ctl 時對 EPOLLET 按位或將描述符注冊到 epoll 實例以得到邊緣出發(fā)模式。

代碼示例:

function Poller:register(fd, r, w)
    local ev = self.ev[0]
    // 事件注冊
    ev.events = bit.bor(C.EPOLLET, C.EPOLLERR, C.EPOLLHUP)
        // 讀事件
    if r then
        ev.events = bit.bor(ev.events, C.EPOLLIN)
    end
    if w then
        // 寫事件
        ev.events = bit.bor(ev.events, C.EPOLLOUT)
    end
    // 將文件描述符設置到事件對象中
    ev.data.u64 = fd
    // 調用 epoll_ctl 注冊
    local rc = C.epoll_ctl(self.fd, C.EPOLL_CTL_ADD, fd, ev)
    if rc < 0 then errors.get(rc):abort() end
end

我們以圖表的方式來說明邊緣觸發(fā)模式的工作原理类垫。我們采用先前的例子進行說明司光,進程 A 注冊了 4 個描述符到 epoll 實例,其中 fd3 是 socket 描述符悉患。

假如 t1 時刻残家,輸出字節(jié)流到到達 fd3 引用的 socket 描述符。

image.png

假定 t4 時售躁,進程將調用 epoll_wait坞淮。

如果 t4 時茴晋,fd2、fd3 已經(jīng)就緒回窘,epoll_wait 調用返回 fd2诺擅、fd3 已經(jīng)就緒。

image.png

假定進程 t6 時啡直,再一次調用 epoll_wait烁涌。
此時 fd1 已經(jīng)就緒,而 fd3 對應的 socket 描述符在 t4 跟 t6 之間沒有數(shù)據(jù)到達酒觅。

在水平觸發(fā)模式下撮执,調用 epoll_wait 將返回 fd1 給進程,因為此時 fd1 是唯一一個就緒的描述符舷丹。然而在邊緣觸發(fā)模式下抒钱,該調用將會阻塞,因為在 t4 跟 t6 之間沒有 socket 數(shù)據(jù)到達颜凯。


image.png

參考

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
禁止轉載继效,如需轉載請通過簡信或評論聯(lián)系作者。
  • 序言:七十年代末装获,一起剝皮案震驚了整個濱河市瑞信,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌穴豫,老刑警劉巖凡简,帶你破解...
    沈念sama閱讀 222,183評論 6 516
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異精肃,居然都是意外死亡秤涩,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,850評論 3 399
  • 文/潘曉璐 我一進店門司抱,熙熙樓的掌柜王于貴愁眉苦臉地迎上來筐眷,“玉大人,你說我怎么就攤上這事习柠≡纫ィ” “怎么了?”我有些...
    開封第一講書人閱讀 168,766評論 0 361
  • 文/不壞的土叔 我叫張陵资溃,是天一觀的道長武翎。 經(jīng)常有香客問我,道長溶锭,這世上最難降的妖魔是什么宝恶? 我笑而不...
    開封第一講書人閱讀 59,854評論 1 299
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上垫毙,老公的妹妹穿的比我還像新娘霹疫。我一直安慰自己,他們只是感情好综芥,可當我...
    茶點故事閱讀 68,871評論 6 398
  • 文/花漫 我一把揭開白布更米。 她就那樣靜靜地躺著,像睡著了一般毫痕。 火紅的嫁衣襯著肌膚如雪征峦。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 52,457評論 1 311
  • 那天消请,我揣著相機與錄音栏笆,去河邊找鬼。 笑死臊泰,一個胖子當著我的面吹牛蛉加,可吹牛的內容都是我干的。 我是一名探鬼主播缸逃,決...
    沈念sama閱讀 40,999評論 3 422
  • 文/蒼蘭香墨 我猛地睜開眼针饥,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了需频?” 一聲冷哼從身側響起丁眼,我...
    開封第一講書人閱讀 39,914評論 0 277
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎昭殉,沒想到半個月后苞七,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 46,465評論 1 319
  • 正文 獨居荒郊野嶺守林人離奇死亡挪丢,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 38,543評論 3 342
  • 正文 我和宋清朗相戀三年蹂风,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片乾蓬。...
    茶點故事閱讀 40,675評論 1 353
  • 序言:一個原本活蹦亂跳的男人離奇死亡惠啄,死狀恐怖,靈堂內的尸體忽然破棺而出任内,到底是詐尸還是另有隱情撵渡,我是刑警寧澤,帶...
    沈念sama閱讀 36,354評論 5 351
  • 正文 年R本政府宣布族奢,位于F島的核電站姥闭,受9級特大地震影響丹鸿,放射性物質發(fā)生泄漏越走。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 42,029評論 3 335
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望廊敌。 院中可真熱鬧铜跑,春花似錦、人聲如沸骡澈。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,514評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽肋殴。三九已至囤锉,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間护锤,已是汗流浹背官地。 一陣腳步聲響...
    開封第一講書人閱讀 33,616評論 1 274
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留烙懦,地道東北人驱入。 一個月前我還...
    沈念sama閱讀 49,091評論 3 378
  • 正文 我出身青樓,卻偏偏與公主長得像氯析,于是被迫代替她去往敵國和親亏较。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 45,685評論 2 360

推薦閱讀更多精彩內容