iOS 多线程 --- GCD

一.进程&线程

  • 进程:是程序执行过程中分配和管理资源的一个基本单位。
  • 线程:是程序执行过程中任务调度和执行的一个基本单位。
  • 一个进程里面有多个线程,线程是进程的一部分

二. 任务

任务:就是要执行什么操作。

1. 同步执行(sync):
  • 在当前的线程上面执行任务。
  • 不具备开启新线程的能力。
2. 异步执行(async):
  • 可以开启新的线程执行任务。
  • 具备开启新线程的能力。

三. 队列

队列:用于存放任务,遵循FIFO(先进先出)的原则。

1. 串行队列(serial):
  • 队列中每次只执行一个任务,当第一个执行完才能执行第二个。
  • 只能开启一个线程。
2. 并发队列(concurrent):
  • 队列中可以同时执行多个任务。
  • 可以开启多个线程。
3. 两种特殊队列:
  • 全局队列(dispatch_get_global_queue):直接作为普通并发队列使用。
  • 主队列(dispatch_get_main_queue):任务在主线程中执行的串行队列。

四. 使用步骤

注:任务放到...(主队列 & 串行队列 & 全局队列)队列中...(同步 & 异步)执行。

1. 创建队列
  • 串行队列 & 并发队列
// 串行队列
dispatch_queue_t serialQueue = dispatch_queue_create("com.wxh.queue", DISPATCH_QUEUE_SERIAL);
// 并发队列
dispatch_queue_t concurrentQueue = dispatch_queue_create("com.wxh.queue", DISPATCH_QUEUE_CONCURRENT);

注:第一个参数表示队列的唯一标识符DEBUG的时候用的,可以为空,推荐使用类似于APP的BundleID这种逆序域名;第二个参数识别是串行队列还是并发队列串行队列DISPATCH_QUEUE_SERIAL,并发队列用DISPATCH_QUEUE_CONCURRENT

  • 主队列 & 全局队列
// 主队列
dispatch_queue_t mainQueue = dispatch_get_main_queue();
// 全局队列
dispatch_queue_t globalQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);

注:主队列不用传参数。全局队列第一个参数一般用DISPATCH_QUEUE_PRIORITY_DEFAULT,表示优先级的。第二个参数暂时没用,用0即可。

2. 创建任务
// 同步任务创建
dispatch_sync(queue, ^{
});
// 异步任务创建
dispatch_async(queue, ^{
});

注:参数queue就是队列的类型。

3. 小结
  • 并发队列全局队列使用场景是一致的,通常都是使用全局队列
  • 总共有3种队列:全局队列主队列串行队列;有2种任务:同步任务异步任务
  • 共有6种使用方式,能否开启新的线程有同步或者 异步决定,但是否开启新的线程要看当前状况是否需要开启新的线程来决定。
  • sync会照成阻塞现象,sync任务下的队列里面的任务要必须完成一个才能继续下一个。
  • sync阻塞的是队列,不是线程。

五. 使用

1. 同步主队列 syncMain

解读:任务放到主队列同步执行

dispatch_sync(dispatch_get_main_queue(), ^{
    NSLog(@"任务1");
    NSLog(@"任务2");
});
  • 如果在主线程中执行syncMain,会出现死锁现象。
    解释:在主线程上面执行同步主队列syncMain任务,相当于把syncMain任务加到了主队列Main中。当要执行任务1的时候,也要先把任务1加到Main中。因为是同步任务sync,所以要执行任务1之前,必须先等待syncMain任务完成。但是要完成syncMain任务前,又必须执行任务1,这时候syncMain任务和任务1之间就会互相等待,出现死锁现象,程序崩溃。

  • 如果是在子线程中使用syncMain,不开启新的线程。先完成任务1,再完成任务2
    解释:因为是同步任务sync,不具备开启新的线程的能力。在子线程中执行syncMain,相当于吧syncMain任务加到了子线程的队列(这里用队列A表示)中。当要执行任务1任务2的时候,把任务1任务2加到主队列Main中,这个时候,Main中是没有其他任务的,所以不会出现死锁现象。Main是特殊的串行队列,所以先执行完任务1,再执行任务2

2. 异步主队列 asyncMain

解读:任务放到主队列异步执行

dispatch_async(dispatch_get_main_queue(), ^{
    NSLog(@"任务1");
    NSLog(@"任务2");
});

不开启新的线程。先完成任务1,再完成任务2
解释:虽然是异步任务async,具备开启新的线程的能力。但是由于是在主队列Main中执行任务,Main中的任务必须在主线程完成,所以不需要开启新的线程。在主线程中执行asyncMain任务,相当于把asyncMain任务加到主队列Main中。当要执行任务1的时候,也要任务1加到Main中。因为是异步任务async,所以asyncMain任务可以先等待,先执行完任务1,再执行任务2,所以不会出现死锁现象;在子线程执行asyncMain任务,跟子线程执行syncMain任务同等逻辑。

使用场景:做网络请求从后台接口获取到数据之后,需要根据数据更新界面UI,一般都是用asyncMain,在asyncMainblock里面执行刷新界面的操作。

3.同步串行队列 syncSerial

解读:任务放到串行队列同步执行

dispatch_sync(dispatch_queue_create("com.wxh.queue", DISPATCH_QUEUE_SERIAL), ^{
    NSLog(@"任务1");
    NSLog(@"任务2");
});

不会开启新的线程。先完成任务1,再完成任务2
解释:因为是同步任务syncsync不具备开启新的线程。执行syncSerial任务时,syncSerial任务存放在当前线程的队列(这里用队列B表示)中,执行任务1的时候,将任务1放到当前线程的串行队列Serial中,任务2也一样。因为是Serial,所以要先执行完任务1,再执行任务2

问题:同步串行队列syncSerial在主线程上执行,为什么不会出现死锁现象?
回答:在主线程上执行syncSerial任务,syncSerial任务存放在主队列Main当中,而任务1任务2都放在主线程的串行队列Serial中(不是在主队列Main中哦~)。此时,主线程上面有两个队列,一个是存放syncSerial任务的Main,另一个是存放任务1任务2Serial。当执行syncSerial任务中的任务1时,会从主队列Main去到串行队列Serial,然后在Serial继续执行任务2,执行完任务2,回到主队列Main中完成syncSerial任务。

4.异步串行队列 asyncSerial

解读:任务放到串行队列异步执行

dispatch_async(dispatch_queue_create("com.wxh.queue", DISPATCH_QUEUE_SERIAL), ^{
    NSLog(@"任务1");
    NSLog(@"任务2");
});

会开启一条新的线程。先完成任务1,再完成任务2
解释:因为是异步任务async,具备开启新的线程的能力。因为是在串行队列Serial中,只能同时执行一个任务,所以只需要开启一条新的线程。asyncSerial任务存放在当前线程的队列中(这里用队列C表示),而任务1任务2存放在新开启的线程的Serial。当执行asyncSerial任务,要开始执行任务1时,先去到新开启的线程的Serial中,执行完任务1,再执行任务2,然后回到队列C中完成asyncSerial任务。

5. 同步全局队列 syncGlobal

解读:任务放到全局队列同步执行

dispatch_sync(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    NSLog(@"任务1");
    NSLog(@"任务2");
});

不会开启新的线程。先完成任务1,再完成任务2
解释:因为是同步任务sync,不具备开启新的线程能力。虽然是在全局队列Global中,可以多个任务同时进行,但是只有一条线程,所以还是要先完成任务1再执行任务2syncGlobal任务存放在当前线程的队列(这里用队列D表示)中,执行任务1的时候,将任务1放到当前线程的全局队列Global中,任务2也一样。虽然是Global,但是只有一条线程,所以要先执行完任务1,再执行任务2

使用场景:上传多张图片到后台,后台要求一张一张的上传。

6. 异步全局队列 asyncGlobal

解读:任务放到全局队列异步执行

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    NSLog(@"任务1");
    NSLog(@"任务2");
});

会开启多条新的线程。任务1任务2同时执行。
解释:因为是异步任务asyncasync会具备开启新的线程能力。因为是全局队列Global,所以Global里面的任务可以同时执行。asyncGlobal任务存放在当前线程的队列(这里用队列E表示)中,而任务1任务2存放在各自开启的线程队列中。当执行asyncGlobal任务,因为是Global,所以任务1任务2可以同时执行。

使用场景:上传多张图片到后台,可以多张同时上传。

六.GCD线程之间的通信

异步开启子线程执行耗时任务,耗时任务完成,利用主队列回到主线程更新UI。

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    dispatch_sync(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        for (int i = 0; i < 10; i ++) {
            NSLog(@"---%d",i);
            // 模拟耗时操作
            [NSThread sleepForTimeInterval:1];
        }
    });
    dispatch_async(dispatch_get_main_queue(), ^{
        // 耗时操作完成
        NSLog(@"任务完成,回到主线程更新UI。");
    });
});

七.阻塞方法dispatch_barrier

作用:在有多个任务并且使用栅栏方法dispatch_barrier的队列(注意:不能使用全局队列),必须先等待dispatch_barrier前面的任务执行完毕,才能执行dispatch_barrier里面的任务。等待dispatch_barrier里面的任务执行完毕,才能继续执行dispatch_barrier之后的任务。
例子:有三种图片,分别压缩之后,一起上传后后台。

dispatch_queue_t concurrentQueue = dispatch_queue_create("com.wxh.queue", DISPATCH_QUEUE_CONCURRENT);
dispatch_async(concurrentQueue, ^{
    [NSThread sleepForTimeInterval:3];
    NSLog(@"压缩图片1");
});
dispatch_async(concurrentQueue, ^{
    [NSThread sleepForTimeInterval:1];
    NSLog(@"压缩图片2");
});
dispatch_async(concurrentQueue, ^{
    [NSThread sleepForTimeInterval:4];
    NSLog(@"压缩图片3");
});
dispatch_barrier_async(concurrentQueue, ^{
    NSLog(@"将 压缩图片1 压缩图片2 压缩图片3 上传到后台");
});
dispatch_async(concurrentQueue, ^{
    NSLog(@"其他操作");
});

结果:这样子既能让3张图片同时压缩,又能确保3张图片都压缩完之后,才将3张图片上传到后台。

问题1:为什么不能使用 全局队列
解释:苹果官方给的说明是如果使用全局队列,那么dispatch_barrier_async方法将退化成dispatch_async方法。个人觉得,不知道对不对,全局队列没有名字,自定义的并发队列是有名字的,系统需要重新控制队列里面任务的执行操作,必须具体到哪个队列中去重新控制。

问题2:dispatch_barrier_asyndispatch_barrier_syn的区别?
解释:上例中,将dispatch_barrier_asyn替换成dispatch_barrier_syn效果是一样的。它们的区别在于,

  • dispatch_barrier_asyn将自己的任务加入到队列中之后,不用等自己的任务执行完毕,它就将它后面的任务也加入到队列中。然后等待自己的任务执行完毕,才执行后面的任务。
  • dispatch_barrier_syn将自己的任务加入到队列中之后,需要等待自己的任务执行完毕,才能加入后台的任务,并执行后面的任务。

八.延时方法 dispatch_after

dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
});

方法中需要传入一个延时的时间(秒),延时操作里面的任务放到主队列执行。

九.只执行一次(单例) dispatch_once

+ (instancetype)shareInstance{
    static Singleton *single;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        single = [[Singleton alloc] init];
    });
    return single;
}

在程序运行过程中,dispatch_once方法中的代码只会被执行1次,即不影响性能,又能保证线程安全。

  • 原理:dispatch_once方法是根据dispatch_once_t修饰的变量onceToken的值来决定接下来的操作的。
    (1)当onceToken = 0时,说明程序第一次执行dispatch_once方法,直接执行dispatch_onceblock中的代码。
    (2)当onceToken = -1时,说明程序已经执行完过dispatch_once方法,那么跳过dispatch_onceblock的代码,执行block之后的代码。
    (3)当onceToken != 0onceToken != -1时,说明现在有线程(这里用线程A表示)在执行dispatch_once方法,但是还没执行完毕。这个时候,当前这条线程处于阻塞状态,等待线程A执行完毕。当线程A执行完dispatch_once方法时,onceToken的值会变成-1,这时候当前这条线程继续执行。
    (4)单例可以看成是一种特殊的实例,是一个全局的对象,有且只有一个的对象。

十.快速迭代 dispatch_apply

NSLog(@"---");
dispatch_apply(10, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^(size_t index) {
    // 模拟耗时操作
    [NSThread sleepForTimeInterval:(11 - index)];
    NSLog(@"%zu",index);
});
NSLog(@"+++");
 ---
 3
 2
 1
 0
 6
 5
 4
 7
 9
 8
 +++

dispatch_apply是一个快速迭代的方法,类似于for循环

  • 如果方法中传入的是一个串行队列,那么dispatch_apply里面的耗时操作就需要按顺序同步执行(相当于异步串行队列,必须执行完成一个任务之后,才能执行下一个任务。不过一般不会这么做,这样操作就失去了快速迭代的意义)。
  • 如果方法中传入的是一个全局队列,那么里面多个耗时操作就可以同时进行(相当于异步并发队列,可以多个任务同时执行)。
  • 无论传入的是串行队列还是全局队列dispatch_apply方法都会阻塞当前线程等待所有任务执行完毕,才能执行dispatch_apply方法后面的代码(相当于同步任务)。

十一. 队列组 dispatch_group

需求:在填写个人资料页面,我们需要把个人的信息(名字,手机号等)上传到后台,也需要把照片(身份证正反面拍照等)也上传到后台,需要做两个网络请求。当两个网络请求都成功回调之后,返回上一个页面。

1. 第一种方法:使用dispatch_group_notify监听。

NSLog(@"---");
dispatch_group_t group =  dispatch_group_create();
dispatch_group_async(group, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    for (int i = 0; i < 2; ++i) {
        [NSThread sleepForTimeInterval:3];
        NSLog(@"上传个人信息");
    }
});
dispatch_group_async(group, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    for (int i = 0; i < 3; ++i) {
        [NSThread sleepForTimeInterval:1];
        NSLog(@"上传图片资料");
    }
});
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
    NSLog(@"返回上个界面");
});
NSLog(@"===");
---
===
上传图片资料
上传图片资料
上传个人信息
上传图片资料
上传个人信息
返回上个界面
  • dispatch_group_create()创建一个队列组。
  • dispatch_group_async将任务放到队列里面,然后再讲队列放到队列组里面。
  • dispatch_group_notify监听队列组中其他队列的任务完成状态,当所有的任务都执行完成之后,将自身block里面的任务也方法队列组中,执行任务。
  • dispatch_group_notify不会阻塞当前线程。

2. 第二种方法:使用dispatch_group_wait阻塞当前线程。

NSLog(@"---");
dispatch_group_t group =  dispatch_group_create();
dispatch_group_async(group, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    for (int i = 0; i < 2; ++i) {
        [NSThread sleepForTimeInterval:3];
        NSLog(@"上传个人信息");
    }
});
dispatch_group_async(group, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    for (int i = 0; i < 3; ++i) {
        [NSThread sleepForTimeInterval:1];
        NSLog(@"上传图片资料");
    }
});
dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
NSLog(@"返回上个界面");
NSLog(@"===");
---
上传图片资料
上传图片资料
上传个人信息
上传图片资料
上传个人信息
返回上个界面
===
  • 当所有任务都完成之后,才执行dispatch_group_wait后面的任务。
  • dispatch_group_wait会阻塞当前的线程。

3. 第三种方法:使用dispatch_group_enterdispatch_group_leave组合代替dispatch_group_async

NSLog(@"---");
dispatch_group_t group = dispatch_group_create();
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
dispatch_group_enter(group);
dispatch_async(queue, ^{
    for (int i = 0; i < 2; ++i) {
        [NSThread sleepForTimeInterval:3];
        NSLog(@"上传个人信息");
    }
    dispatch_group_leave(group);
});

dispatch_group_enter(group);
dispatch_async(queue, ^{
    for (int i = 0; i < 3; ++i) {
        [NSThread sleepForTimeInterval:1];
        NSLog(@"上传图片资料");
    }
    dispatch_group_leave(group);
});
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
    NSLog(@"返回上个界面");
});
NSLog(@"===");
---
===
上传图片资料
上传图片资料
上传个人信息
上传图片资料
上传个人信息
返回上个界面
  • dispatch_group_enter表示把一个任务放到队列组group,并开始执行。
  • dispatch_group_leave 表示任务执行完毕。
  • dispatch_group_enter + dispatch_group_leave = dispatch_group_async
  • 可以用dispatch_group_notify,同样也可以用dispatch_group_wait(结果跟第2种方法一样)。区别是后者会照成当前线程阻塞,前者不会。
  • group中的所有任务都执行完毕时,才会执行dispatch_group_wait后面的任务,或者执行追加到dispatch_group_notify中的任务。

十二. 信号量 dispatch_semaphore

三个重要方法

  • dispatch_semaphore_create:创建并初始化一个信号总量,一般为0或者1
  • dispatch_semaphore_signal:发送一个信号,即让信号总量+1
  • dispatch_semaphore_wait:如果当前信号总量为0,那么阻塞当前线程,否则。信号总量-1,正常执行。

1. 异步线程变成同步。

需求:有时候需要实时拿到异步里面耗时操作的结果,才能正确的执行之后的代码。

__block NSInteger i = 1;
// 创建一个信号总量为`0`的信号量
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    [NSThread sleepForTimeInterval:2];
    i ++;
    dispatch_semaphore_signal(semaphore);
});
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
NSLog(@"%ld",(long)i);

打印结果为:2
解释:当第一次执行dispatch_semaphore_wait时,信号总量为0,当前线程阻塞。当执行完异步block里面的耗时操作之后,执行了dispatch_semaphore_signal,信号总量+1。从block里面出来第二次执行dispatch_semaphore_wait时,信号总量为1,正常执行。这样就能等到异步执行完之后,再执行接下来的代码(类似于同步执行)。

2. 线程安全(线程锁)

需求:有时候,我们会在多个地方同时对同一个接口进行调用,那如果每次调用过程会对下一次调用的结果有影响(有修改或者更变等操作),那么我们就必须保证该接口同一时间只能被一个地方调用,这就是线程安全

- (void)viewDidLoad {
    [super viewDidLoad];
    self.semaphore = dispatch_semaphore_create(1);
}
- (void)tiaoyongjiekou{
    dispatch_semaphore_wait(self.semaphore, DISPATCH_TIME_FOREVER);
    __weak typeof(self) weakSelf = self;
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        // 模拟耗时操作
        [NSThread sleepForTimeInterval:1.0];
        dispatch_semaphore_signal(weakSelf.semaphore);
    });
}

解释:在外部创建一个信号总量为1的信号量,当第一次调用tiaoyongjiekou方法,执行到dispatch_semaphore_wait时,因为当前信号总量为1,那么正常执行并且信号总量-1(此时信号总量为0)。如果第一次调用还没执行完成,第二次就开始调用,当执行到dispatch_semaphore_wait时,信号总量为0,线程阻塞,只能原地等待。等第一次调用结束,执行完耗时操作之后,执行了dispatch_semaphore_signal,信号总量+1(此时信号总量为1),那么第二次调用才能继续执行。这样就能确保同一时间只被调用一次,确保线安全。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350

推荐阅读更多精彩内容