【转】Objective-C并发编程:API和挑战

并发指的是在同一时间运行多个任务。在单核CPU的情况下,它通过分时的方式实现,如果有多个CPU可用,则是真正意义上的多个任务“并行”执行了。

OS X和iOS提供了多个API支持并发编程。每个API都有自己特殊的功能和限制,适用于完成不同的任务。它们也分布在不同的抽象层次,我们可以通过底层API去做些非常接近硬件的底层操作,但这样的话,我们也需要做更多的事去保证一切运行正常。

并发编程是件非常棘手的事,有着许多复杂的问题和陷阱,而且在使用像GCD或NSOperationQueue这样的API时我们常常忘了这点。这篇文章将首先总体介绍OS X和iOS中不同的并发编程API,然后更深入地研究并发编程本身所固有的、与具体API无关的挑战。

 

OS X和iOS上的并发API

Apple的移动和桌面操作系统提供了相同的并发编程API。这篇文章中,我们来看一下pthread和NSThread、GCD、NSOperationQueue,以及NSRunLoop。从技术上讲,runloops被列在这里有点奇怪,因为他们并不支持真正的并发运行。之所以放在这里将,是因为他们和这个话题关系非常密切,值得我们了解一下。

我们将按从底层到上层的顺序来介绍这些API。之所以这样做是因为上层API都是建立在底层API基础上的。但当你实际使用时,你应该按照相反的顺序来选择:在能实现你的要求的前提下,尽量选上层API,这样能让你的并发模型更简洁。

如果你想知道为什么我们一直建议采用上层抽象和简洁的并发编程代码,你可以阅读这篇文章的第二部分:《并发编程所面临的挑战》,以及Peter Steinberger的这篇关于线程安全的文章

 

线程

线程是进程的子单元,可以单独被操作系统的调度器调度。事实上所有并发API都建立在线程基础上,包括GCD和操作队列。

多线程可以在单核CPU上同时运行(或至少看起来是在同时运行)。操作系统为每条线程分配运算时间片,这样用户感觉就好像多个任务同时被执行一样。如果CPU是多核的,多条线程就可以真正地被同时执行,完成某个操作的总体时间也因此能被缩短。

你可以通过Instruments的CPU 视图来了解你的代码或你所使用的框架代码在多核CPU上是怎样被调度的。

有一点需要牢记的是:你无法控制你的代码在何时何地被调度,以及它何时被暂停、暂停多久以供其他任务运行。线程的这种调度机制是个非常强大的技术,但同时也非常复杂,我们将在稍后对其进行说明。

暂时先抛开这种复杂性不谈,你可以使用POSIX线程API,或者Objective-C对此API进行的封装——NSThread,来创建你的线程。这里有个小例子,演示使用pthread来实现在一百万个数字中寻找最小值和最大值。它产生了4个并发运行的线程。从这个例子复杂的代码中可以很明显地看出为什么你不会希望直接使用pthread函数编程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
struct threadInfo {
    uint32_t * inputValues;
    size_t count;
};
 
struct threadResult {
    uint32_t min;
    uint32_t max;
};
 
void * findMinAndMax(void *arg)
{
    struct threadInfo const * const info = (struct threadInfo *) arg;
    uint32_t min = UINT32_MAX;
    uint32_t max = 0;
    for (size_t i = 0; i < info->count; ++i) {
        uint32_t v = info->inputValues[i];
        min = MIN(min, v);
        max = MAX(max, v);
    }
    free(arg);
    struct threadResult * const result = (struct threadResult *) malloc(sizeof(*result));
    result->min = min;
    result->max = max;
    return result;
}
 
int main(int argc, const char * argv[])
{
    size_t const count = 1000000;
    uint32_t inputValues[count];
 
    // Fill input values with random numbers:
    for (size_t i = 0; i < count; ++i) {
        inputValues[i] = arc4random();
    }
 
    // Spawn 4 threads to find the minimum and maximum:
    size_t const threadCount = 4;
    pthread_t tid[threadCount];
    for (size_t i = 0; i < threadCount; ++i) {
        struct threadInfo * const info = (struct threadInfo *) malloc(sizeof(*info));
        size_t offset = (count / threadCount) * i;
        info->inputValues = inputValues + offset;
        info->count = MIN(count - offset, count / threadCount);
        int err = pthread_create(tid + i, NULL, &findMinAndMax, info);
        NSCAssert(err == 0, @"pthread_create() failed: %d", err);
    }
    // Wait for the threads to exit:
    struct threadResult * results[threadCount];
    for (size_t i = 0; i < threadCount; ++i) {
        int err = pthread_join(tid[i], (void **) &(results[i]));
        NSCAssert(err == 0, @"pthread_join() failed: %d", err);
    }
    // Find the min and max:
    uint32_t min = UINT32_MAX;
    uint32_t max = 0;
    for (size_t i = 0; i < threadCount; ++i) {
        min = MIN(min, results[i]->min);
        max = MAX(max, results[i]->max);
        free(results[i]);
        results[i] = NULL;
    }
 
    NSLog(@"min = %u", min);
    NSLog(@"max = %u", max);
    return 0;
}

NSThread是Objective-C对pthread的封装,用于将操作简化。通过这种封装,代码在Cocoa环境中看起来就友好多了。比如,你可以将线程定义成NSThread的子类,将你想在后台运行的代码封装起来。针对上面那个例子,我们可以这样来定义NSThread的子类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@interface FindMinMaxThread : NSThread
@property (nonatomic) NSUInteger min;
@property (nonatomic) NSUInteger max;
- (instancetype)initWithNumbers:(NSArray *)numbers;
@end
 
@implementation FindMinMaxThread {
    NSArray *_numbers;
}
 
- (instancetype)initWithNumbers:(NSArray *)numbers
{
    self = [super init];
    if (self) {
        _numbers = numbers;
    }
    return self;
}
 
- (void)main
{
    NSUInteger min;
    NSUInteger max;
    // process the data
    self.min = min;
    self.max = max;
}
@end

要想启动一条新线程,需要创建一个新的线程对象,并调用它的start函数:

1
2
3
4
5
6
7
8
9
10
11
12
NSSet *threads = [NSMutableSet set];
NSUInteger numberCount = self.numbers.count;
NSUInteger threadCount = 4;
for (NSUInteger i = 0; i < threadCount; i++) {
    NSUInteger offset = (count / threadCount) * i;
    NSUInteger count = MIN(numberCount - offset, numberCount / threadCount);
    NSRange range = NSMakeRange(offset, count);
    NSArray *subset = [self.numbers subarrayWithRange:range];
    FindMinMaxThread *thread = [[FindMinMaxThread alloc] initWithNumbers:subset];
    [threads addObject:thread];
    [thread start];
}

现在我们可以通过检测线程的 isFinished属性来侦测何时我们创建的线程都结束了。有兴趣的同学可以去试一试,然而我最想说的是,直接操作线程,不管是用pthread还是NSThread,都相当痛苦。

直接使用线程会引发的一个问题就是:如果你的代码和底层框架代码都生成自己的线程,激活的线程的数量将以指数级别增长。这在大型项目中是普遍存在的问题,举个例子,比如你创建了8条线程去使用8核的CPU,而你在这些线程中所调用的框架代码也这样做(因为它不知道你当前创建了多少线程),你很快就会有几十个甚至上百个线程。虽然涉及到的代码各司其职,然而,总体运行效果是会出问题的。线程不是免费的午餐,每条线程都会消耗内存及内核资源。

接下来,我们将介绍两组基于队列的并发API:GCD和操作队列。他们通过集中管理一个公用线程池的方式来缓解问题。

 

Grand Central Dispatch(GCD)

Grand Central Dispatch(GCD)是在 OS X 10.6 和 iOS4 中引入的,旨在让开发者能更好地利用用户终端上的多核CPU。我们将在我们关于底层并发API的文章中更深入的介绍GCD的细节。

通过GCD,你不再直接和线程打交道,而是在队列中添加代码块,GCD在后台管理这一个线程池。GCD会决定你的代码块将在哪个线程被执行,而且它会根据可用的系统资源来管理这些线程。这样就缓解了创建过多线程的问题,因为线程现在是集中管理从而把开发者解放了出来。

GCD带来的另一个重要改变是:开发者以队列的,而不是线程的思维去处理多项操作,这种新的并发编程的思维方式也更容易实施。

GCD公开了5个不同的队列:1个运行在主线程中的main queue;3个具有不同优先级的后台队列;以及一个具有更低优先级的后台队列,用于直接控制I/O。另外,你还可以创建自定义队列,这些队列可以是串行的,也可以是并行的。自定义队列很强大,你在其中调度的所有代码块都将被放入到系统的一个全局队列和它的线程池中。

gcd-queues@2x

使用优先级不同的多个线程,乍一听很简单,然而,我们强烈建议你在几乎任何情况下都使用默认优先级。在不同优先级的队列中调度任务,当这些任务访问共享资源的时候,很容易引起不希望的结果。这最终将导致你整个程序崩溃停止,因为某些低优先级的任务阻塞了高优先级任务的执行。在下面讲的优先级反转中你将看到更多这类现象。

虽然GCD是稍低层次的C语言API,但它用起来很简单。这也容易让人忘记在使用GCD时所有并发编程的注意事项和陷阱仍然是存在的。请一定要阅读下文中的并发编程的挑战,以了解潜在的问题。我们在本期话题中有另一篇文章对GCD的API进行了更深入的讲解,以及有价值的提示。

 

操作队列Operation Queue

操作队列是Cocoa对GCD公开的队列模型的一个抽象。GCD提供更底层一些的控制,相对而言,操作队列在他之上实现了许多更便捷易用的特性,所以通常对应用开发者来说操作队列是最好也是最安全的选择。

NSOperationQueue类有两种类型的队列:主队列和自定义队列。主队列在主线程中运行,自定义队列在后台运行。不管是哪种队列,都是NSOperation的子类。

你可以通过两种方式来定义你的操作:重写main函数,或重写start函数。前者非常容易操作,但灵活性差一些。在这种方式中,你不需要去管理像isExecuting和isFinished这样的状态属性,可以简单认为当main返回时操作就完成了。

1
2
3
4
5
6
@implementation YourOperation
    - (void)main
    {
        // do your work here ...
    }
@end

如果你想有更多控制权以及想在操作中执行异步任务,你可以重写start函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@implementation YourOperation
    - (void)start
    {
        self.isExecuting = YES;
        self.isFinished = NO;
        // start your work, which calls finished once it's done ...
    }
 
    - (void)finished
    {
        self.isExecuting = NO;
        self.isFinished = YES;
    }
@end

注意在这种方式中,你必须手动管理操作的状态。为了让操作队列能捕捉到这些状态的变化,状态属性要定义成与KVO兼容。所以,确保在你不通过默认的访问函数去设置他们的值时要发送合适的KVO消息。

操作队列支持取消机制,故你进行操作时需要例行检查isCancelled属性以判断是否继续运行。

1
2
3
4
5
6
- (void)main
{
    while (notDone && !self.isCancelled) {
        // do your processing
    }
}

定义好了你的操作类(NSOperation操作类的子类)后,往队列里添加操作就非常简单了:

1
2
3
NSOperationQueue *queue = [[NSOperationQueue alloc] init];
YourOperation *operation = [[YourOperation alloc] init];
[queue  addOperation:operation];

另外,你也可以往操作队列中添加代码块。这非常简单,例如:你想在主队列中调度一个一次性任务:

1
2
3
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
    // do something...
}];

虽然代码块的方式可以很放方便地在队列中安排任务,但定义NSOperation操作类的子类的方式非常有助于调试。如果你重写了操作类的description方法,你可以很容易地识别出安排在某个队列中的全部操作。

除了用队列安排operation或代码块这些基本操作外,operation queue还提供了一些GCD难以做好的功能。例如,你可以通过设置maxConcurrentOperationCount属性轻松地控制一个queue中有多少个operation能被同时执行,设为1的时候变成一个串行队列,可用于实现隔离的目的。

另一个方便的功能是根据队列中operation的优先级对其进行排序,这不同于GCD的队列优先级,它只会影响到一个队列中所有operation的执行顺序。如果你需要对超过那五个标准优先级的操作有更多的控制权,你可以像这样指定他们之间的依赖关系:

1
2
3
[intermediateOperation addDependency:operation1];
[intermediateOperation addDependency:operation2];
[finishedOperation addDependency:intermediateOperation];

这段简单的代码保证了operation1和operation2都将在intermediateOperation之前执行。操作间的依赖关系是非常强大的机制,可用来指定一种良好的执行顺序。你能创建一个操作组,让它们在依赖它们的操作前被执行,或者在并发队列中顺序执行操作。

从本质上说,operation queue的性能稍逊于GCD,但在大多数情况下这可以忽略不计,operation queue是并发编程的首选。

 

Run Loop

严格来说,run loop并不是一种(像GCD或operation queue那样的)并发机制,因为它们不能并发执行任务。然而,run loop在主dispatch/operation queue 中直接配合任务的执行,并且他们提供一种异步执行代码的机制。

Run loop比operation queue或GCD易用得多,因为你既不需要处理并发的复杂性又能让任务异步执行。

一个run loop总是和一个具体的线程绑定。与主线程相关联的主run loop在Cocoa和CocoaTouch应用中起着关键作用,因为它负责处理UI 事件、定时器,以及其他内核事件。每当使用定时器、使用NSURLConnection或调用performSelector:withObject:afterDelay:时,run loop就在后台运行以执行异步任务。

无论何时使用基于run loop的函数,都要牢记住run loop可以在不同模式下运行。每种模式都定义了一组run loop将响应的事件。在主run loop中临时地将某些任务的优先级提到其他任务之上是一种聪明的做法。

一个典型的例子是iOS中的滚动(scrolling)。当你滚动时,run loop不运行在默认模式下,因此,它将不响应一些事件,例如你之前定义的定时器。一旦滚动停止,run loop 又回到默认模式,在这种模式中的事件又能被响应了。如果你希望定时器在滚动时也能被触发,你需要将它加到NSRunLoopCommonModes模式下的run loop中。

主线程总会有一个主run loop,但其它线程就没有默认的run loop了。你也可以为其它线程设置一个run loop,但常常不需要这样做。大多数时候用主run loop更简单些。如果你想执行一些繁重的任务,又不想在主线程执行,你可以在你的代码被主run loop 调用后将它分派到另一个queue上。相关内容,可以看看Chris写的《common background practices》这篇文章。

如果你真的需要在非主线程上建立run loop,别忘了为他添加一个input source。如果run loop 没有配置input source,任何运行它的尝试都会马上退出。

 

并发编程的挑战

并发编程伴随着很多陷阱。除了最基本的操作外,一旦你尝试做其它复杂些的事情,就难以看清并发运行的多个任务之间交互协作的各种状态。问题往往以不确定的方式出现,加大了并发编程的调试难度。

关于并发编程的难以预见的行为,有个著名的例子:1995年,NASA(美国国家宇航局)向火星发射“探险者号”探测器。然而才刚刚成功登录我们这颗相邻的红色星球不久,任务就戛然而止。火星探测器莫名其妙地不断重启——这是一种叫做“优先级反转”的现象导致的,即:低优先级的线程阻塞了高优先级线程。稍后我们将会对这件事有更多的介绍,这里举这个例子主要是为了说明即使拥有丰富的资源和大量的天才,并发性仍然可能处处为难你。

 

资源共享

并发编程许多问题的根源是通过多线程对共享资源进行访问。资源可以是:某个属性或对象、内存、网络设备、文件,等等。在多线程间共享的任何东西都有可能引起冲突,你需要采取安全措施来避免这类冲突发生。

为了说明这个问题,我们来看个简单的例子:将一个整数资源用作计数器。假设有2条并发运行的线程,A和B,两条线程同时想增加这个计数器的值。问题在于:你用C或Objective-C写的一条语句对于CPU来说并非只有一条机器指令,这里要增加这个计数器,首先需要从内存读取它当前的值,然后增加1,最后写回内存。

让我们来想象一下当两条线程同时试图执行这个操作时可能引发的问题。例如,线程A和线程B都想从内存读取这个计数器的值;假设这个值是17。然后线程A将计数器加1并将结果18写回内存。同时,线程B紧接着也将计数器加1并将结果18写回内存。这时这个计数器数据就冲突了:从17加了两次1,值却是18.

race-condition@2x

这个问题叫做“资源竞争”,当有多条线程去访问共享资源时,如果不能保证在一条线程完成对共享资源的操作后另一条线程才去访问该资源,就会发生这种问题。如果你不仅仅是往内存中写一个简单的整数,而是更复杂的数据结构,甚至有可能出现这种情况:在你写到一半的时候,另一条线程试图从内存中读取它的值,然后就读到了半新半旧或未初始化的数据。为防止这种情况发生,多线程需要通过互斥的方式去访问共享资源。

事实上,情况会比这个还要复杂得多,因为现代CPU为了优化性能会改变对内存数据的读写顺序(乱序操作)。

 

互斥

互斥访问指的是:一次只有一个线程能对某个资源进行访问。为做到这点,想访问某个资源的每一条线程都需要请求一个该资源的互斥锁,完成操作后,释放这个锁,然后其它线程才能访问该资源。

locking@2x

除了保证对资源的互斥访问,锁还必须处理由乱序操作引起的问题。如果你不能保证CPU访问内存的顺序与你程序指令的顺序一致,那仅仅是保证互斥访问还不够。为了解决这个由CPU优化策略引起的副作用,需要用到内存屏障。设置内存屏障保证了没有乱序操作可以越过屏障。

当然,互斥锁本身的实现不能存在资源竞争。这可不是简单的小事,在现代CPU上需要使用特殊指令。你可以在Daniel的文章《low-level concurrency techniques》中了解到更多关于原子操作的知识。

Objective-C在语言级别上就提供了对属性的锁支持,只要将它们声明为atomic(原子的)即可。事实上,有些属性甚至默认就是原子的。将属性声明为原子的意味着每次访问他们都会有隐含的加锁/解锁操作。看来,将所有的属性都声明为原子的似乎是万无一失的做法,但,锁操作是要付出代价的。

得到资源锁总是伴随着性能上的代价。得到和释放锁的操作本身不能存在资源竞争,这在多核系统上不是件小事。并且,一个线程在请求获取一个锁时,可能需要等待,因为其它线程可能已经占用这个锁。在这种情况下,该线程将进入休眠状态,并在其它线程释放该锁时需要被唤醒。所有这些操作都是要消耗资源并且复杂的。

锁有不同类型。一些锁在没有“锁竞争”时不耗资源,但在竞争情况下则表现不佳。另外一些锁在正常情况下显得比较昂贵(耗资源),但在“锁竞争”情况下,却能有效控制资源的消耗(锁竞争指的是这样的情况:有一条或多条线程试图获取一个已被占用的锁)。

这里需要做下权衡:获取和释放锁会带来代价(锁开销),因此你需要确保不会不断地进入和离开临界区(例如:请求和释放锁);与此同时,如果你请求的锁管了太大块的代码(译者注:这样临界区代码执行完就需要较长时间),又会增加锁竞争的风险而使得其它线程常常因为等待锁而不能继续工作。这不是件容易取舍的事。

我们常常能看到这样一种现象:本应并发运行的代码,却由于共享资源锁设置方式的原因,只有一条线程在运行。在多核CPU上,预见你的代码将以怎样的方式被调度常常有重要意义。你可以使用Instrument的CPU 策略视图来了解你是否有效利用了可用的CPU资源。

 

死锁

互斥锁解决了资源竞争的问题,但不幸的是同时又(在现有问题中)引入了一个新的问题:死锁。死锁发生在多条线程相互等待从而被阻塞的情况下。

dead-lock@2x

请思考下面这段交换两个变量的值的代码:

1
2
3
4
5
6
7
8
9
10
11
void swap(A, B)
{
    lock(lockA);
    lock(lockB);
    int a = A;
    int b = B;
    A = b;
    B = a;
    unlock(lockB);
    unlock(lockA);
}

大多数情况下,这段代码运行良好。但当两条线程同时调用它们并且传入的形参刚好是顺序相反的两个变量时:

1
2
swap(X, Y); // thread 1
swap(Y, X); // thread 2

程序就会因为死锁而结束。线程1获取了X的锁,线程2获取了Y的锁。现在他们都要等到另外一个锁,但又将永远都无法获取到。

同样,你在线程间共享越多资源、使用越多的锁,你陷入死锁的风险就越大。这也是要尽量使操作简单且在线程间共享尽量少的资源的另一个原因。建议阅读《low-level concurrency APIs》这篇文章的“doing things asynchronously” 这一段。

 

饥渴(译注:“写饥渴”)

就在你认为要考虑的问题已经够多的时候,又有一个新问题半路冒出来。锁住共享资源会导致读-写问题。大多数情况下,把对资源的读访问限制为一次只允许一条线程访问太过浪费。因此,只要对某个资源没有加“写”锁,都允许多条线程可以共用一个“读”锁(译者注:即在有“读”锁未释放时又加上新的“读”锁)。(然而,)这种情况下,如果有一条线程等待获得“写”锁,而在等待过程中“读”锁不断增加(译者注:即不断有线程申请读该资源从而在该资源上加上“读”锁;而“读”锁不释放,“写”锁就无法加上去),那么该线程就会陷入“写饥渴”。

为解决这个问题,需要比简单的读/写锁更好的解决方案,例如:赋予写操作优先权,或使用“读-拷贝-更新”算法。Daniel在他的《low-level concurrency techniques》这篇文章中介绍了怎样利用GCD实现一种“多读/单写”的模式,这种模式可避免写饥渴问题。

 

优先级反转

我们以NASA的探索者号火箭探测器遭受并发问题的例子来作为本节的开头。现在我们来更深入地了解下,为什么探索者号会失败,为什么你的应用程序也会遭受同样的问题,这个问题叫做:“优先级反转”。

优先级反转是指这样一种情况:优先级较低的任务阻止了优先级较高的任务的执行,造成了事实上的任务优先级倒置。由于GCD公开了具有不同优先级的后台队列,其中甚至包括I/O队列,很适合用来了解优先级反转的可能性。

问题发生在你让高优先级和低优先级的任务共享资源的时候。当低优先级的任务获取了某个公共资源的锁,它本应该很快完成任务然后释放锁,好让高优先级的任务可以获取该资源继续执行,且没有明显延时。然而,因为当低优先级的任务占有锁时高优先级任务会被阻塞,这就给了中等优先级(优先级介于此处描述的“低优先级”和“高优先级”之间)的任务执行的机会,由于中优先级的任务此时是所有可运行的任务中优先级最高的,它会抢占低优先级任务的资源。这样一来,中优先级的任务妨碍了低优先级的任务释放锁,从而其优先级在事实上超过了还在等待的高优先级的任务。

priority-inversion@2x

在你的代码中,事情可能不会像火星探测器不断重启那样富有戏剧性,因为优先级反转通常以不太严重的形式发生。

一般情况下,不要使用不同的优先级。程序常常都是因为高优先级的代码要等待低优先级代码结束以被执行而意外终止。当你使用GCD时,请总是使用默认优先级的队列(直接使用,或作为目标队列)。如果你使用了其它优先级,十有八九,事情只会变得更糟。

通过这个得到的教训是:使用具有不同优先级的多个队列理论上看起来很容易,但它给并发编程增加了复杂性和不可预测性。你以后如果遇到诸如高优先级的任务莫名其妙被阻断的奇怪问题,可能就会想起这篇文章以及优先级反转的问题,这可是连NASA的工程师都遭遇过的问题。

 

总结

本文希望说明并发编程的复杂性和它存在的问题,无论相关API看起来有多么简单,由此产生的问题都会变得难以观测,调试这类问题也常常非常困难。

另一方面,并发编程又是有效利用现代多核CPU计算能力的强有力的工具。关键就在于要让你的并发模型尽可能简单,这样你就能限制需要的锁的数量。

我们推荐一个安全的模式:在主线程上提取所有你想要操作的数据,然后用一个operation queue在后台执行实际的工作,最后回到主queue去处理从后台操作获取的结果。这样,你就不需要自己去加任何的锁,大大降低了出错的概率。

 

 



原文链接: Florian Kugler   翻译: 伯乐在线 温小嘉
译文链接: http://blog.jobbole.com/52647/
转载必须在正文中标注并保留原文链接、译文链接和译者等信息。]

posted @ 2014-01-09 16:23  ymonke  阅读(229)  评论(0编辑  收藏  举报