線程間同步方式詳解

線程間同步方式

引言

不同線程間對臨界區(qū)資源的訪問可能會引起常見的并發(fā)問題蔽挠,我們希望線程原子式的執(zhí)行一系列指令,但由于單處理器上的中斷瓜浸,我們必須想一些其他辦法以同步各線程澳淑,本文就來介紹一些線程間的同步方式。

互斥鎖

互斥鎖(又名互斥量)插佛,強調(diào)的是資源的訪問互斥:互斥鎖是用在多線程多任務(wù)互斥的杠巡,當(dāng)一個線程占用了某一個資源,那么別的線程就無法訪問雇寇,直到這個線程unlock氢拥,其他的線程才開始可以利用這個資源。

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

注意理解trylock函數(shù)锨侯,這與普通的lock不一樣嫩海,普通的lock函數(shù)在資源被鎖住時會被堵塞,直到鎖被釋放识腿。

trylock函數(shù)是非阻塞調(diào)用模式出革,也就是說如果互斥量沒被鎖住,trylock函數(shù)將把互斥量加鎖渡讼,并獲得對共享資源的訪問權(quán)限; 如果互斥量被鎖住了骂束,trylock函數(shù)將不會阻塞等待而直接返回EBUSY,表示共享資源處于忙狀態(tài)成箫,這樣就可以避免死鎖或餓死等一些極端情況發(fā)生展箱。

探究底層,實現(xiàn)一個鎖

實現(xiàn)一個鎖必須需要硬件的支持蹬昌,因為我們必須要保證鎖也是并發(fā)安全的混驰,這就需要硬件支持以保證鎖內(nèi)部是原子實現(xiàn)的。

很容易想到維護一個全局變量flag,當(dāng)該變量為0時栖榨,允許線程加鎖昆汹,并設(shè)置flag為1;否則婴栽,線程必須掛起等待满粗,直到flag為0.

typedef struct lock_t {
    int flag;
}lock_t;

void init(lock_t &mutex) {
    mutex->flag = 0;
}

void lock(lock_t &mutex) {
    while (mutex->flag == 1) {;} //自旋等待變量為0才可進入
    mutex->flag = 1;
}

void unlock(lock_t &mutex) {
    mutex->flag = 0;
}

這是基于軟件的初步實現(xiàn),初始化變量為0愚争,線程自旋等待變量為0才可進入映皆,這看上去似乎并沒有什么毛病,但是仔細思考轰枝,這是有問題的:

image

當(dāng)線程恰好通過while判定時陷入中斷捅彻,此時并未設(shè)置flag為1,另一個線程闖入鞍陨,此時flag仍然為0步淹,通過while判定進入臨界區(qū),此時中斷湾戳,回到原線程贤旷,原線程繼續(xù)執(zhí)行广料,也進入臨界區(qū)砾脑,這就造成了同步問題。

在while循環(huán)中艾杏,僅僅設(shè)置mutex->flag == 1是不夠的韧衣,盡管他是一個原語,我們必須有更多的代碼购桑,同時畅铭,當(dāng)我們引入更多代碼時,我們必須保證這些代碼也是原子的勃蜘,這就意味著我們需要硬件的支持硕噩。

我們思考上面代碼為什么會失敗缭贡?原因是當(dāng)退出while循環(huán)時炉擅,在這一時刻flag仍然為0,這就給了其他線程搶入臨界區(qū)的機會阳惹。

解決辦法也很直觀 —— 在退出while時谍失,借助硬件支持保證flag被設(shè)置為1。

測試并加鎖(TAS)

我們編寫如下函數(shù):

int TestAndSet(int *old_ptr, int new) {
    int old = *old_ptr;
    *old_ptr = new;
    return old;
}

同時重新設(shè)置while循環(huán):

void lock(lock_t &mutex) {
    while (TestAndSet(mutex->flag莹汤, 1) == 1) {;} //自旋等待變量為0才可進入
    mutex->flag = 1;
}

這里快鱼,我們借助硬件,保證TestAndSet函數(shù)是原子執(zhí)行的,現(xiàn)在鎖可以正確的使用了抹竹。當(dāng)flag為0時线罕,我們通過while測試時已經(jīng)將flag設(shè)置為1了,其他線程已經(jīng)無法進入臨界區(qū)窃判。

比較并交換(CAS)

我們編寫如下函數(shù):

int CompareAndSwap(int *ptr, int expected, int new) {
    int actual = *ptr;
    if (actual == expected) {
        *ptr = new;
    }
    return actual;
}

同樣的闻坚,硬件也應(yīng)該支持CAS原語以保證CAS內(nèi)部也是安全的,現(xiàn)在重新設(shè)置while:

void lock(lock_t &mutex) {
    while (CompareAndSwap(mutex->flag兢孝, 0窿凤, 1) == 1) {;} //自旋等待變量為0才可進入
    mutex->flag = 1;
}

現(xiàn)在鎖可以正確的使用了,當(dāng)flag為0時跨蟹,我們通過while測試時已經(jīng)將flag設(shè)置為1了雳殊,其他線程已經(jīng)無法進入臨界區(qū)。

此外你可能發(fā)現(xiàn)CAS所需要更多的寄存器窗轩,在將來研究synchronozation時夯秃,你會發(fā)現(xiàn)它的妙處。

另一個問題痢艺,過多的自旋仓洼?

你可能發(fā)現(xiàn)了,盡管一個線程未能獲得鎖堤舒,其仍然在不斷while循環(huán)以占用CPU資源色建,一個辦法就是當(dāng)線程未能獲得鎖,進入休眠以釋放CPU資源(條件變量)舌缤,當(dāng)一個線程釋放鎖時箕戳,喚醒一個正在休眠的線程。不過這樣也有缺點国撵,進入休眠與喚醒一個鎖也是需要時間的陵吸,當(dāng)一個線程很快就能釋放鎖時,多等等是比陷入休眠更好的選擇介牙。

Linux下采用兩階段鎖壮虫,第一階段線程自旋一定時間或次數(shù)等待鎖的釋放,當(dāng)達到一定時間或一定次數(shù)時环础,進入第二階段囚似,此時線程進入休眠。

回到互斥鎖

互斥鎖提供了并發(fā)安全的基本保證喳整,互斥鎖用于保證對臨界區(qū)資源的安全訪問谆构,但何時需要訪問資源并不是互斥鎖應(yīng)該考慮的事情,這可能是條件變量該考慮的事情框都。

如果線程頻繁的加鎖和解鎖搬素,效率是非常低效的呵晨,這也是我們必須要考慮到的一個點。

信號量

信號量并不用來傳送資源熬尺,而是用來保護共享資源摸屠,理解這一點是很重要的,信號量 s 的表示的含義為同時允許訪問資源的最大線程數(shù)量粱哼,它是一個全局變量季二。

在進程中也可以使用信號量,對于信號量的理解進程中與線程中并無太大差異揭措,都是用來保護資源胯舷,關(guān)于更多信號量的理解參見這篇文章: JavaLearningNotes/進程間通信方式。

來考慮一個上面簡單的例子:兩個線程同時修改而造成錯誤绊含,我們不考慮讀者而僅僅考慮寫者進程桑嘶,在這個例子中共享資源最多允許一個線程修改資源,因此我們初始化 s 為1躬充。

開始時逃顶,A率先寫入資源,此時A調(diào)用P(s)充甚,將 s 減一以政,此時 s = 0,A進入共享區(qū)工作伴找。

此時盈蛮,線程B也想進入共享區(qū)修改資源,它調(diào)用P(s)發(fā)現(xiàn)此時s為0疆瑰,于是掛起線程眉反,加入等待隊列。

A工作完畢穆役,調(diào)用V(s),它發(fā)現(xiàn)s為0并檢測到等待隊列不為空梳凛,于是它隨機喚醒一個等待線程耿币,并將s加1,這里喚醒了B韧拒。

B被喚醒淹接,繼續(xù)執(zhí)行P操作,此時s不為0叛溢,B成功執(zhí)行將s置為0并進入工作區(qū)塑悼。

此時C想要進入工作區(qū)......

可以發(fā)現(xiàn),在無論何時只有一個線程能夠訪問共享資源楷掉,這就是信號量做的事情厢蒜,他控制進入共享區(qū)的最大進程數(shù)量,這取決于初始化s的值。此后斑鸦,在進入共享區(qū)之前調(diào)用P操作愕贡,出共享區(qū)后調(diào)用V操作,這就是信號量的思想巷屿。

有名信號量

有名信號量以文件的形式存在固以,即時是不同進程間的線程也可以訪問該信號量,因此可以用于不同進程間的多線程間的互斥與同步嘱巾。

創(chuàng)建打開有名信號量

sem_t *sem_open(const char *name, int oflag);
sem_t *sem_open(const char *name, int oflag, mode_t mode, unsigned int value);
//成功返回信號量指針憨琳;失敗返回SEM_FAILED,設(shè)置errno

name是文件路徑名旬昭,value設(shè)置為信號量的初始值栽渴。

關(guān)閉信號量,進程終止時稳懒,會調(diào)用它

int sem_close(sem_t *sem);  //成功返回0闲擦;失敗返回-1,設(shè)置errno

刪除信號量场梆,立即刪除信號量名字墅冷,當(dāng)其他進程都關(guān)閉它時,銷毀它

int sem_unlink(const char *name);

等待信號量或油,測試信號量的值寞忿,如果其值小于或等于0,那么就等待(阻塞)顶岸;一旦其值變?yōu)榇笥?就將它減1腔彰,并返回

int sem_wait(sem_t *sem);
int sem_trywait(sem_t *sem);
//成功返回0;失敗返回-1辖佣,設(shè)置errno

當(dāng)信號量的值為0時霹抛,sem_trywait立即返回,設(shè)置errno為EAGAIN卷谈。如果被某個信號中斷杯拐,sem_wait會過早地返回,設(shè)置errno為EINTR

發(fā)出信號量世蔗,給它的值加1端逼,然后喚醒正在等待該信號量的進程或線程

int sem_post(sem_t *sem);

成功返回0;失敗返回-1污淋,不會改變它的值顶滩,設(shè)置errno,該函數(shù)是異步信號安全的寸爆,可以在信號處理程序里調(diào)用它

無名信號量

無名信號量存在于進程內(nèi)的虛擬空間中礁鲁,對于其他進程是不可見的盐欺,因此無名信號量用于一個進程體內(nèi)各線程間的互斥和同步,使用如下API:

(1)sem_init 功能:用于創(chuàng)建一個信號量,并初始化信號量的值救氯。 函數(shù)原型:

int sem_init (sem_t* sem, int pshared, unsigned int value);

函數(shù)傳入值: sem:信號量找田。pshared:決定信號量能否在幾個進程間共享。由于目前LINUX還沒有實現(xiàn)進程間共享信息量着憨,所以這個值只能取0墩衙。

(2)其他函數(shù)

int sem_wait       (sem_t* sem);
int sem_trywait   (sem_t* sem);
int sem_post       (sem_t* sem);
int sem_getvalue (sem_t* sem);
int sem_destroy   (sem_t* sem);

功能:

sem_wait和sem_trywait相當(dāng)于P操作,它們都能將信號量的值減一甲抖,兩者的區(qū)別在于若信號量的值小于零時漆改,sem_wait將會阻塞進程,而sem_trywait則會立即返回准谚。

sem_post相當(dāng)于V操作挫剑,它將信號量的值加一,同時發(fā)出喚醒的信號給等待的線程柱衔。

sem_getvalue 得到信號量的值樊破。

sem_destroy 摧毀信號量。

如果某個基于內(nèi)存的信號量是在不同進程間同步的唆铐,該信號燈必須存放在共享內(nèi)存區(qū)中哲戚,這要只要該共享內(nèi)存區(qū)存在,該信號燈就存在艾岂。

總結(jié)

無名信號量存在于內(nèi)存中顺少,有名信號量是存在于磁盤上的,因此無名信號量的速度更快王浴,但只適用于一個獨立進程內(nèi)的各線程脆炎;有名信號量可以速度欠缺,但可以使不同進程間的線程同步氓辣,這是通過共享內(nèi)存實現(xiàn)的秒裕,共享內(nèi)存是進程間的一種通信方式。

你可能發(fā)現(xiàn)了筛婉,當(dāng)信號量的值s為1時架谎,信號量的作用于互斥鎖的作用是一樣的唾糯,互斥鎖只能允許一個線程進入臨界區(qū),而信號量允許更多的線程進入臨界區(qū)归敬,這取決于信號量的值為多少响蓉。

條件變量

什么是條件變量硕勿?

在互斥鎖中,線程等待flag為0才能進入臨界區(qū)枫甲;信號量中P操作也要等待s不為0......在多線程中源武,一個線程等待某個條件是很常見的扼褪,互斥鎖實現(xiàn)一節(jié)中,我們采用自旋是否有一個更專門粱栖、更高效的方式實現(xiàn)條件的等待话浇?

它就是條件變量!條件變量(condition variable)是利用線程間共享的全局變量進行同步的一種機制闹究,主要包括兩個動作:一個線程等待某個條件為真幔崖,而將自己掛起;另一個線程設(shè)置條件為真渣淤,并通知等待的線程繼續(xù)赏寇。

由于某個條件是全局變量,因此條件變量常使用互斥鎖以保護(這是必須的价认,是被強制要求的)嗅定。

條件變量與互斥量一起使用時,允許線程以無競爭的方式等待特定的條件發(fā)生用踩。

線程可以使用條件變量來等待某個條件為真渠退,注意理解并不是等待條件變量為真,條件變量(cond)是在多線程程序中用來實現(xiàn)"等待-->喚醒"邏輯常用的方法脐彩,用于維護一個條件(與是條件變量不同的概念)碎乃,并不是說等待條件變量為真或為假。條件變量是一個顯式的隊列丁屎,當(dāng)條件不滿足時荠锭,線程將自己加入等待隊列,同時釋放持有的互斥鎖晨川;當(dāng)一個線程改變條件時证九,可以喚醒一個或多個等待線程(注意此時條件不一定為真)。

在條件變量上有兩種基本操作:

  • 等待(wait):一個線程處于等待隊列中休眠共虑,此時線程不會占用互斥量愧怜,當(dāng)線程被喚醒后,重新獲得互斥鎖(可能是多個線程競爭)妈拌,并重新獲得互斥量拥坛。
  • 通知(signal/notify):當(dāng)條件更改時,另一個線程發(fā)送通知以喚醒等待隊列中的線程尘分。

相關(guān)函數(shù)

1. 初始化

條件變量采用的數(shù)據(jù)類型是pthread_cond_t,猜惋,在使用之前必須要進行初始化,,這包括兩種方式:

靜態(tài): 直接設(shè)置條件變量cond為常量PTHREAD_COND_INITIALIZER培愁。

動態(tài): pthread_cond_init函數(shù), 是釋放動態(tài)條件變量的內(nèi)存空間之前, 要用pthread_cond_destroy對其進行清理著摔。

int pthread_cond_init(pthread_cond_t *restrict cond, pthread_condattr_t *restrict attr);
int pthread_cond_destroy(pthread_cond_t *cond);
//成功則返回0, 出錯則返回錯誤編號.

注意:條件變量占用的空間并未被釋放。

cond:要初始化的條件變量定续;attr:一般為NULL谍咆。

2. 等待條件

int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restric mutex);
int pthread_cond_timedwait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex, const struct timespec *restrict timeout);
//成功則返回0, 出錯則返回錯誤編號.

這兩個函數(shù)分別是阻塞等待和超時等待禾锤,堵塞等到進入等待隊列休眠直到條件修改而被喚醒;超時等待在休眠一定時間后自動醒來摹察。

進入等待時線程釋放互斥鎖恩掷,而在被喚醒時線程重新獲得鎖。

3. 通知條件

int pthread_cond_signal(pthread_cond_t *cond);
int pthread_cond_broadcast(pthread_cond_t *cond);
//成功則返回0, 出錯則返回錯誤編號.

這兩個函數(shù)用于通知線程條件已被修改供嚎,調(diào)用這兩個函數(shù)向線程或條件發(fā)送信號黄娘。

用法與思考

條件變量用法模板:

pthread_cond_t cond;  //條件變量
mutex_t mutex;  //互斥鎖
int flag; //條件

//A線程
void threadA() {
    Pthread_mutex_lock(&mutex);  //保護臨界資源,因為線程會修改全局條件flag
    while (flag == 1) //等待某條件成立
        Pthread_cond_wait(&cond, &mutex);  //不成立則加入隊列休眠查坪,并釋放鎖
    ....dosomthing
    ....change flag   //條件被修改
    Pthread_cond_signal(&cond); //發(fā)送信號通知條件被修改
    Pthread_mutex_unlock(&mutex); //放松信號后盡量快速釋放鎖寸宏,因為被喚醒的線程會嘗試獲得鎖
}


//B線程
void threadB() {
    Pthread_mutex_lock(&mutex);  //保護臨界資源
    while (flag == 0) //等待某條件成立
        Pthread_cond_wait(&cond, &mutex);  //不成立則加入隊列休眠,并釋放鎖
    ....dosomthing
    ....change flag   //條件被修改
    Pthread_cond_signal(&cond); //放松信號后盡量快速釋放鎖偿曙,因為被喚醒的線程會嘗試獲得鎖
    Pthread_mutex_unlock(&mutex);
}

通過上面的一個例子氮凝,應(yīng)該很好理解條件變量與條件的區(qū)別,條件變量是一個邏輯望忆,它并不是while循環(huán)里的bool語句罩阵,我相信很多初學(xué)者都有這么一個誤區(qū),即條件變量就是線程需要等待的條件启摄。條件是條件稿壁,線程等待條件而不是等待條件變量,條件變量使得線程更高效的等待條件成立歉备,是一組等待 — 喚醒 的邏輯傅是。

注意這里仍然要使用while循環(huán)等待條件,你可能會認(rèn)為明明已經(jīng)上鎖了別的線程無法強入蕾羊。事實上當(dāng)線程A陷入休眠時會釋放鎖喧笔,而當(dāng)其被喚醒時,會嘗試獲得鎖龟再,而正在其嘗試獲得鎖時书闸,另一個線程B現(xiàn)在嘗試獲得鎖,并且搶到鎖進入臨界區(qū)利凑,然后修改條件浆劲,使得線程A的條件不再成立,線程B返回哀澈,此時線程A終于獲得鎖了牌借,并進入臨界區(qū),但此時線程A的條件根本已經(jīng)不成立割按,他不該進入臨界區(qū)走哺!

此外,被喚醒也不代表條件成立了哲虾,例如上述代碼線程B修改flag = 3丙躏,并且喚醒線程A,這里線程A的條件根本不符合束凑,所以必須重復(fù)判定條件晒旅。互斥鎖和條件變量的例子告訴我們:在等待條件時汪诉,總是使用while而不是if废恋!

陷入休眠的線程必須釋放鎖也是有意義的,如果不釋放鎖扒寄,其他線程根本無法修改條件鱼鼓,休眠的線程永遠都不會醒過來!

實踐——讀寫者鎖

讀取鎖——共享该编;寫入鎖——獨占迄本。即:讀線程可以加多個,而寫線程只能有一個课竣,并且讀者和寫者不能同時工作嘉赎。

這種情況下由于允許多個讀者共享臨界區(qū)效率會高效,我們來考慮實現(xiàn)的問題:只允許一個寫者工作于樟,那么一定需要一個互斥量或二值信號量來維護公条,我們稱為寫者鎖;由于讀者和寫者不能同時工作迂曲,第一個讀者必須嘗試獲取寫者鎖靶橱,而一旦讀者數(shù)量大于1,則后續(xù)讀者無須嘗試獲取寫者鎖而可直接進入路捧,注意到這里存在全局讀者數(shù)量變量关霸,因此讀者也需要一個鎖以維護全局讀者數(shù)量,最后一個退出的讀者必須負責(zé)釋放讀者鎖鬓长。

知曉原理谒拴,快去自己動手實現(xiàn)一個讀寫者鎖把!

Linux下通過pthread_rwlock函數(shù)族實現(xiàn)涉波。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末英上,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子啤覆,更是在濱河造成了極大的恐慌苍日,老刑警劉巖,帶你破解...
    沈念sama閱讀 211,376評論 6 491
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件窗声,死亡現(xiàn)場離奇詭異相恃,居然都是意外死亡,警方通過查閱死者的電腦和手機笨觅,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,126評論 2 385
  • 文/潘曉璐 我一進店門拦耐,熙熙樓的掌柜王于貴愁眉苦臉地迎上來耕腾,“玉大人,你說我怎么就攤上這事杀糯∩ò常” “怎么了?”我有些...
    開封第一講書人閱讀 156,966評論 0 347
  • 文/不壞的土叔 我叫張陵固翰,是天一觀的道長狼纬。 經(jīng)常有香客問我,道長骂际,這世上最難降的妖魔是什么疗琉? 我笑而不...
    開封第一講書人閱讀 56,432評論 1 283
  • 正文 為了忘掉前任,我火速辦了婚禮歉铝,結(jié)果婚禮上盈简,老公的妹妹穿的比我還像新娘。我一直安慰自己犯戏,他們只是感情好送火,可當(dāng)我...
    茶點故事閱讀 65,519評論 6 385
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著先匪,像睡著了一般种吸。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上呀非,一...
    開封第一講書人閱讀 49,792評論 1 290
  • 那天坚俗,我揣著相機與錄音,去河邊找鬼岸裙。 笑死猖败,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的降允。 我是一名探鬼主播恩闻,決...
    沈念sama閱讀 38,933評論 3 406
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼剧董!你這毒婦竟也來了幢尚?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,701評論 0 266
  • 序言:老撾萬榮一對情侶失蹤翅楼,失蹤者是張志新(化名)和其女友劉穎尉剩,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體毅臊,經(jīng)...
    沈念sama閱讀 44,143評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡理茎,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,488評論 2 327
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片皂林。...
    茶點故事閱讀 38,626評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡朗鸠,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出式撼,到底是詐尸還是另有隱情童社,我是刑警寧澤,帶...
    沈念sama閱讀 34,292評論 4 329
  • 正文 年R本政府宣布著隆,位于F島的核電站,受9級特大地震影響呀癣,放射性物質(zhì)發(fā)生泄漏美浦。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,896評論 3 313
  • 文/蒙蒙 一项栏、第九天 我趴在偏房一處隱蔽的房頂上張望浦辨。 院中可真熱鬧,春花似錦沼沈、人聲如沸流酬。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,742評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽芽腾。三九已至,卻和暖如春页衙,著一層夾襖步出監(jiān)牢的瞬間摊滔,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,977評論 1 265
  • 我被黑心中介騙來泰國打工店乐, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留艰躺,地道東北人。 一個月前我還...
    沈念sama閱讀 46,324評論 2 360
  • 正文 我出身青樓眨八,卻偏偏與公主長得像腺兴,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子廉侧,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 43,494評論 2 348

推薦閱讀更多精彩內(nèi)容