iOS并发编程--GCD、操作队列、线程

现在iOS的多线程方案主要有以下这几种:

  1. GCD(Grand Central Dispatch):使用dispatch queue(分派队列)执行tasks(任务),苹果公司推荐使用;
  2. NSOperationQueue和NSOperation:使用operation queue(操作队列)执行operations(操作),苹果公司推荐使用;
  3. NSThread:苹果公司封装的基于OC对象的线程对象,但是需要自己进行线程生命周期的控制,以及对共享资源的同步操作。与前两种方式相比,不推荐使用这种方式。
  4. Pthreads:POSIX thread(Portable Operating System Interface of UNIX【可移植操作系统接口】线程),是线程的POSIX标准,定义了创建和操纵线程的一套API。iOS和Mac OS中的线程的底层实现就是基于它的,是很底层的API,除非要自己从底层开始实现一套多线程方案,否则一般不会用这个。

一、GCD(Grand Central Dispatch)

在讨论GCD用法之前,我们需要先对GCD涉及的几个概念有些了解:

  1. 任务(tasks):就是需要做的事情,也就是要用多线程执行的一段代码。在GCD中提交给分派队列的任务必须被包含在一个函数或者block对象中,随后调用dispatch_[a]sync()方法或者dispatch_[a]sync_f()方法添加到对应分派队列中。

Block objects are a C language feature introduced in OS X v10.6 and iOS 4.0 that are similar to function pointers conceptually, but have some additional benefits. Instead of defining blocks in their own lexical scope, you typically define blocks inside another function or method so that they can access other variables from that function or method. Blocks can also be moved out of their original scope and copied onto the heap, which is what happens when you submit them to a dispatch queue. -- 加入到Dispatch queue的block会被复制到堆中

  1. 分派队列(dispatch queue):任务是通过分派到分派队列中后,系统才会自动管理一些线程执行对应任务的。GCD中队列分为两种:串行队列(Serial queues)并行队列(Concurrent queues)
  • 串行队列:队列的先进先出(FIFO)原则,串行队列会根据任务被加入到队列的顺序,依次取出任务执行,一个执行完才能开始下一个。当前正在运行的任务是在由dispatch queue管理的特定的线程执行(但是这个任务的线程跟下个任务的线程不一定一样)。串行队列通常用于对特定资源的同步访问(类似于“锁”机制,防止共享资源的同时访问)。你可以创建任意个串行队列,对于各个串行队列之间来说,就相当于他们中正在执行的任务是在并行执行的

使用
dispatch_queue_t dispatch_queue_create(const char *label, dispatch_queue_attr_t attr);
方法创建dispatch queue。
第一个参数传递队列名,如,“com.example.myqueue”;
第二个参数attr传递创建队列的类型,In macOS 10.7 and later or iOS 4.3 and later, specify DISPATCH_QUEUE_SERIAL (or NULL) to create a serial queue or specify DISPATCH_QUEUE_CONCURRENT to create a concurrent queue. In earlier versions, you must specify NULL for this parameter.(一般会创建串行队列吧,并行队列通常会通过dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);获取)

dispatch_queue_create()方法创建的队列,在非ARC内存管理模式下,要注意对queue的内存管理。

  • 并行队列:队列的先进先出(FIFO)原则,串行队列会并行地执行一个或多个任务,但是这些任务取出来开始执行的顺序还是依照的它们加入到队列中的顺序(后一个加入的并不用等到前一个执行完才能从队列中取出开始执行,而是依次都取出,然后放入到另一个新的线程中也执行,所以只是差一个取出的时间,很短,几乎可以忽略不计,所以可以看做是这些任务在同时执行)。这些任务所在的线程也都是由dispatch queue管理的,系统会根据情况处理当前应该同时开几个线程用于执行这些并发任务。

任务是要执行的代码;队列是用于保存以及管理任务的;线程是代码执行的通道,负责从队列中取任务执行。
然后放入队列中的任务的执行方式有两种,同步执行异步执行
同步执行方式对应调用GCD中的dispatch_sync()或dispatch_sync_f()方法;
异步执行方式对应调用GCD中的dispatch_async()或dispatch_async_f()方法;

调用同步方法执行任务时,无论任务时加在什么队列中,都需要按顺序先执行“这些任务”,“前一个任务”执行完成才能执行“下一个任务”,“当前正在执行的代码”也需要等待“这些任务”执行完成才能继续执行后面的代码!(用的都是调用GCD代码的同一个线程,一次只能执行一个任务,所以当前代码等待,任务一个一个顺序执行,全部执行完成后,当前代码继续往后执行!)但是注意,如果要在串行队列中同步执行某些任务时,这些任务不能加入到当前同样的串行队列中,比如在主线程中不能将一个任务添加到main queue,又调用dispatch_sync()方法执行(因为串行队列要当前的任务完了才能下一个,但是同步又要求停下当前任务,等同步任务执行完毕,这会造成队列的死锁)。官方有下面的说法:

Important: You should never call the dispatch_sync or dispatch_sync_f function from a task that is executing in the same queue that you are planning to pass to the function. This is particularly important for serial queues, which are guaranteed to deadlock, but should also be avoided for concurrent queues.(官方是说串行队列这么做一定会deadlock,并行队列也最好不要这么做!)
Do not call the dispatch_sync function from a task that is executing on the same queue that you pass to your function call. Doing so will deadlock the queue. If you need to dispatch to the current queue, do so asynchronously using the dispatch_async function.

调用异步方法执行任务时,依据任务添加到的队列类型的不同,执行任务的线程安排会不一样。分发到主队列的任务由 runloop 处理,而分发到其他队列的任务由线程池处理。
  在主线程中,使用异步方法执行加入到主线程的任务时,因为主线程队列是串行队列,所以任务需要顺序执行,所以新任务需要等待当前任务执行完成(后续代码运行结束)才能开始运行,而且新任务会在主线程中运行,并不会开启新线程。(串行队列与主队列表现一样,异步执行添加到串行队列中的任务,也需要等串行队列的当前任务的后续代码执行完成,然后不开启新线程,在同一个线程中执行新任务!)
  但是并行队列不一样,用异步方法执行添加到异步队列中的任务时,系统会开辟多条新的线程,将任务依次取出放入不同线程中同时执行,具体执行完成的顺序依情况而定。(异步执行并发队列中的任务,这是系统并发率最高的用法,可以同时执行很多个任务)。

文字看得比较绕,这边有一篇博客有代码示例说明,比较明晰:
iOS多线程中,队列和执行的排列组合结果分析

串行队列可以保证你的任务顺序执行。而且串行队列可以替代锁对共享数据和一些可变类型数据进行保护(更高效)。只要你是通过异步方法执行加入到串行队列中的任务的话,那么这个串行队列就不会发生死锁

Serial queues are useful when you want your tasks to execute in a specific order. A serial queue executes only one task at a time and always pulls tasks from the head of the queue. You might use a serial queue instead of a lock to protect a shared resource or mutable data structure. Unlike a lock, a serial queue ensures that tasks are executed in a predictable order. And as long as you submit your tasks to a serial queue asynchronously, the queue can never deadlock.

When creating serial queues, try to identify a purpose for each queue, such as protecting a resource or synchronizing some key behavior of your application.(例如,在异步处理完一些数据之后,通过main队列这个串行队列,将数据同步显示到界面中,UI操作都是需要在主线程中进行的!)

If you implemented your tasks using blocks, you can add your blocks to either a serial or concurrent dispatch queue. If a specific order is required, you would always add your blocks to a serial dispatch queue. If a specific order is not required, you can add the blocks to a concurrent dispatch queue or add them to several different dispatch queues, depending on your needs.
If you implemented your tasks using operation objects, the choice of queue is often less interesting than the configuration of your objects. To perform operation objects serially, you must configure dependencies between the related objects. Dependencies prevent one operation from executing until the objects on which it depends have finished their work.

二、NSOperationQueue和NSOperation

NSOperation和NSOperationQueue是苹果公司对于GCD的面向对象的封装,由于面向对象,所以与OC代码的整体风格一致,所以用起来更好理解。

在这种方式中,有操作(operations)操作队列(operation queue)的概念,与GCD中的任务(tasks)和**分配队列(dispatch queue)相对应。

  • 操作(operations):使用这种多线程编程方式时,我们需要运行的代码需要封装在一个operation对象中,它应该是NSOperation类的一个实例。NSOperation本身是抽象类,可以继承这个类来进行自定义operation类,同时Foundation框架中也有我们可以直接使用的具体的子类:NSInvocationOperation(Swift没有,Because it's not type-safe or ARC-safe)和NSBlockOperation。

操作通常是通过加入到操作队列中执行的,但是operation对象也可以通过调用start方法手动执行。当然,手动调用start方法并不能保证操作会与你其他的代码并行执行。The isConcurrent method of the NSOperation class tells you whether an operation runs synchronously or asynchronously with respect to the thread in which its start method was called. By default, this method returns NO, which means the operation runs synchronously in the calling thread.(默认isConcurrent方法返回的是NO,也就是说操作对象默认是在调用线程中同步执行的,并不是并发执行的) -- 使用start方法执行的operation对象,其任务默认会在当前线程执行。但是 NSBlockOperation 还有一个方法:addExecutionBlock: ,通过这个方法可以给 Operation 添加多个执行 Block。这样 Operation 中的任务会并发执行,它会在主线程和其它的多个线程执行这些任务(注意还是会占用当前线程,如果是主线程的话,就会造成屏幕无法响应。)

If you want to implement a concurrent operation—that is, one that runs asynchronously with respect to the calling thread—you must write additional code to start the operation asynchronously. For example, you might spawn a separate thread, call an asynchronous system function, or do anything else to ensure that the start method starts the task and returns immediately and, in all likelihood, before the task is finished.(要实现并发的操作,那就需要添加额外的代码,保证任务代码会并发执行)

Most developers should never need to implement concurrent operation objects. If you always add your operations to an operation queue, you do not need to implement concurrent operations. When you submit a nonconcurrent operation to an operation queue, the queue itself creates a thread on which to run your operation. Thus, adding a nonconcurrent operation to an operation queue still results in the asynchronous execution of your operation object code. The ability to define concurrent operations is only necessary in cases where you need to execute the operation asynchronously without adding it to an operation queue.(只要我们通过operations queue处理operations我们是不用去实现一个并发操作对象的。我们将一个非并发的operation对象添加到一个operation queue时,这个queue会自动创建一个线程来运行operation对象中代码,也就是是加入到operation queue中的非并发operations对象中的代码还是能够异步地执行。)

  • 操作队列(operations):操作队列是一个NSOperationQueue类的实例。目前为止,使用操作队列是执行操作对象中任务的最简单的办法。你的应用会为你创建和维护它所需要的所有的操作队列。一个应用可以含有任意多个的operationqueue,但是实际上同时执行的operation的个数还是会由operation queue根据实际情况控制在一个合适的数量的。所以创建额外多的operation queue并不意味着你能够同时执行额外更多的operation。

操作队列类NSOperationQueue,我们可以通过new方法创建普通队列,也可以通过mainQueue类方法获取到主队列
主队列是绑定到主线程的默认操作队列,加入到这个队列中的operations,会一个一个排队到主线程中执行(期间可能还会有UI事件或者一些系统事件也是需要主线程执行的。)。
普通队列是通过NSOperationQueue类的初始化方法得到的队列。加入到普通队列中的任务会在其他线程中并行执行。

In most cases, operations are executed shortly after being added to a queue, but the operation queue may delay execution of queued operations for any of several reasons. Specifically, execution may be delayed if queued operations are dependent on other operations that have not yet completed. Execution may also be delayed if the operation queue itself is suspended or is already executing its maximum number of concurrent operations. (大部分时候,operation在被加入到操作队列中的很短时间之后就会开始执行,但是也可能会因为一下原因延后执行的时间,比如说某个operation添加了对另一个operation的依赖、操作队列本身被暂停了或者已经达到了最大的并发数等)

You should make all necessary configuration and modifications to an operation object before adding it to a queue, because once added, the operation may be run at any time, which may be too late for a change to have the intended effect.(对operation的配置操作需要在它被加入到queue中之前进行,否则可能无效)

Although the NSOperationQueue class is designed for the concurrent execution of operations, it is possible to force a single queue to run only one operation at a time. The setMaxConcurrentOperationCount: method lets you configure the maximum number of concurrent operations for an operation queue object. Passing a value of 1 to this method causes the queue to execute only one operation at a time. Although only one operation at a time may execute, the order of execution is still based on other factors, such as the readiness of each operation and its assigned priority. Thus, a serialized operation queue does not offer quite the same behavior as a serial dispatch queue in Grand Central Dispatch does. If the execution order of your operation objects is important to you, you should use dependencies to establish that order before adding your operations to a queue.(我们会发现,加入到operation queue中的操作都会被并行地执行,因为NSOperationQueue类就是为operation的并发执行设计的。但是我们也是可以做到让一个operation queue每次只执行一个operation的,只要将maxConcurrentOperationCount最大并发数设置为1就可以了。但就算如此,这个队列中操作的执行顺序也会受每个操作的就绪状态以及相关优先级等因素影响。所以说,NSOperationQueue中的一个每次只执行一个operation的operation queue与GCD中的串行队列serial dispatch queue的表现并不是完全一致的。如果operation之间的操作顺序很重要的话,那么我们应该给operation之间添加依赖!)

约束之间添加依赖

An operation queue is the Cocoa equivalent of a concurrent dispatch queue and is implemented by the NSOperationQueue class. Whereas dispatch queues always execute tasks in first-in, first-out order, operation queues take other factors into account when determining the execution order of tasks. Primary among these factors is whether a given task depends on the completion of other tasks. You configure dependencies when defining your tasks and can use them to create complex execution-order graphs for your tasks.(不像分派队列dispatch queue总是以先进先出的顺序执行任务,操作队列operation queue会依据一些其他因素来决定任务的执行顺序,其中最主要的因素就是operation之间是否添加有相应的依赖关系)。

Dependencies are a way for you to serialize the execution of distinct operation objects. An operation that is dependent on other operations cannot begin executing until all of the operations on which it depends have finished executing. Thus, you can use dependencies to create simple one-to-one dependencies between two operation objects or to build complex object dependency graphs.(依赖是一种顺序化执行一些operation对象的方式。依赖于另一个operation的operation对象,只有在它所依赖的对象执行完成之后才能开始执行。)

To establish dependencies between two operation objects, you use the addDependency:method of NSOperation. This method creates a one-way dependency from the current operation object to the target operation you specify as a parameter. This dependency means that the current object cannot begin executing until the target object finishes executing. Dependencies are also not limited to operations in the same queue. Operation objects manage their own dependencies and so it is perfectly acceptable to create dependencies between operations and add them all to different queues. One thing that is not acceptable, however, is to create circular dependencies between operations. Doing so is a programmer error that will prevent the affected operations from ever running.(使用NSOperation的addDependency:方法创建两个operation对象之间的依赖关系。依赖关系不仅仅局限于同一个操作队列中的操作之间,操作对象会独自管理他们自己的依赖关系,所以在操作与操作之间添加约束关系然后将他们添加到不同的操作队列中是完全可行的。然而,循环依赖是不可行的,则会导致操作无法被执行。)

When all of an operation’s dependencies have themselves finished executing, an operation object normally becomes ready to execute. (If you customize the behavior of the isReady method, the readiness of the operation is determined by the criteria you set.) If the operation object is in a queue, the queue may start executing that operation at any time. If you plan to execute the operation manually, it is up to you to call the operation’s start method.

Important: You should always configure dependencies before running your operations or adding them to an operation queue. Dependencies added afterward may not prevent a given operation object from running.(要在operation对象开始运行或者将他们添加到操作队列之前添加operation对象之间的约束,迟了的话就发挥不了作用了。)
Dependencies rely on each operation object sending out appropriate KVO notifications whenever the status of the object changes. If you customize the behavior of your operation objects, you may need to generate appropriate KVO notifications from your custom code in order to avoid causing issues with dependencies.(依赖关系的实现需要依靠operation对象内部的KVO通知机制,所以如果自定义的operation类需要支持添加依赖的话,就需要创建相应的KVO通知了!)

三、NSThread

NSThread是苹果封装的线程对象,可以直接操控线程。使用NSThread创建的线程需要我们手动管理相关的生命周期,使用起来没有GCD和OperationQueue方便。

NSObject对象的方法隐式创建后台线程

[anObject performSelectorInBackground:@selector(anSelTask) withObject:nil];

NSObject类中的performSelectorInBackground:withObject:方法,可以隐式地创建和启动用于执行对象中方法的新线程。该线程会作为后台次要进程立刻启动,而当前进程会立刻返回(Swift中去除了这个方法,因为内部不安全:The performSelector: method and related selector-invoking methods are not imported in Swift because they are inherently unsafe.)。

NSThread类显式创建和管理线程

初始化方法有:

  • detachNewThreadSelector:toTarget:withObject:
    这个方法功能等同于NSObject类的performSelectorInBackground:withObject:方法,会创建并启动新的线程执行方法中的代码。
  • initWithTarget:selector:object:
    这个方法会创建新线程,但是却不会启动该线程。线程对象创建完毕之后可以进行一些配置,比如优先级等,随后通过调用线程对象的start方法启动线程。

四、一些用法小结

4.1 各种多线程编程方案,从其他线程回到主线程的方法

  • NSThread方案:调用NSObject的下列方法(Swift移除了performSelector的方法,因为内部不安全)
- (void)performSelectorOnMainThread:(SEL)aSelector withObject:(id)arg waitUntilDone:(BOOL)wait 
或者
-(void)performSelector:(SEL)aSelector onThread:(NSThread *)thr withObject:(id)arg waitUntilDone:(BOOL) wait;(传递主线程[NSThread mainThread]作为参数)
  • NSOperationQueue方案:将操作添加到主操作队列中
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
    // 需要在主线程执行的任务
}];
  • GCD方案:将任务添加到主分派队列中(主分派队列是串行队列,同步异步调用都是得顺序执行的,一般串行队列用异步不会发生死锁问题)
dispatch_async(dispatch_get_main_queue(), ^{
    // 需要在主线程中执行的任务
});

4.2 各种多线程编程方式,线程同步的方法

线程同步一般用在多个线程访问共享资源的时候,保证同时只有一个线程在访问。GCD中的同步执行方法一般就用在这种时候。

  • NSThread方案:使用锁NSLock(锁有各种类型)、或者@synchronized指令(会隐式创建锁)
/**
 * 使用NSLock对象(互斥锁)
 */
[lockObject lock];
// 共享资源相关代码
[lockObject unlock];

/**
 * 使用@synchronized指令(互斥锁)
 */
@synchronized(self) {
    // 共享资源相关代码
}
/**
 * 1. 全局的 NSOperationQueue, 所有的操作添加到同一个queue中
 * 2. 设置 queue 的 maxConcurrentOperationCount 为 1
 * 3. 如果后续操作需要Block中的结果,需要在操作添加到队列之后调用就需要调用waitUntilFinished方法(阻塞当前线程,一直等到当前操作完成,才允许执行后面的)!
 */
NSBlockOperation *operation = [NSBlockOperation blockOperationWithBlock:^{
    // 这里是需要同步执行的代码,修改剩余的火车票数
    NSInteger ticket = lastTicket;
    [NSThread sleepForTimeInterval:1];
    NSLog(@"%ld - %@",ticket, [NSThread currentThread]);
    ticket -= 1;
    lastTicket = ticket;
}];

[queue addOperation:operation];
[operation waitUntilFinished];
//后续要做的事
  • GCD方案:GCD中的加入到串行队列的任务会顺序执行(对于串行队列中的任务能够有效保护共享资源,但是对于调用GCD的当前线程的任务则不一定,如果串行队列中任务是使用同步方法执行,那么没问题,当前任务会停住,直到串行队列中添加的代码执行完毕才能接下去执行当前的后续代码;但是如果串行队列中任务是使用并行方法执行的,那么当前任务不会被停住,会继续执行完当前任务,然后再执行串行队列中的任务,这就会导致当前任务的后续代码没能获取到添加的任务的结果就在执行了!)所以GCD方式需要进行同步的话,还是需要通过同步方法执行任务(不管在什么队列,就都会先执行同步任务,执行完毕后,才继续执行后续代码!)。
// 使用同步方法执行任务
dispatch_sync(queue, ^{
    NSInteger ticket = lastTicket;
    [NSThread sleepForTimeInterval:0.1];
    NSLog(@"%ld - %@",ticket, [NSThread currentThread]);
    ticket -= 1;
    lastTicket = ticket;
});

五、相关参考

  1. Concurrency Programming Guide -- 苹果官方
  2. 关于iOS多线程,你看我就够了 -- 伯恩的遗产——有一些总结会有些问题吧,比如说通过异步方式执行添加到串行队列中的任务,并不一定就是在其他线程中执行!
  3. iOS开发系列--并行开发其实很容易 -- KenshinCui
  4. 深入理解GCD -- 小敏的博客——这篇博客讲解的东西比较底层(看着好晕,→_←)
  5. iOS多线程中,队列和执行的排列组合结果分析
  6. iOS多线程全面解读(二):GCD -- GCD一些其他用法,如dispatch_barrier_sync等,保存可以看看
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,387评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,845评论 1 298
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 110,091评论 0 246
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,308评论 0 214
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,662评论 3 288
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,795评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,008评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,743评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,466评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,687评论 2 249
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,181评论 1 262
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,531评论 3 258
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,177评论 3 239
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,126评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,902评论 0 198
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,862评论 2 283
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,734评论 2 274

推荐阅读更多精彩内容