基于协程的编程方式在移动端研发的思考及最佳实践

简介: 超级App的性能和代码痛点 在iOS开发中线程使用特别方便,但是多线程使用不当引发的崩溃问题很多 多线程访问引发野指针问题 多线程访问引发容器类崩溃问题 多线程访问引发过渡释放问题 以手机淘宝为例,整个生命周期大量使用线程,多线程使用不当引发的崩溃问题占比达到了60%以上** 为了解决多线程.

超级App的性能和代码痛点

在iOS开发中线程使用特别方便,但是多线程使用不当引发的崩溃问题很多

  • 多线程访问引发野指针问题
  • 多线程访问引发容器类崩溃问题
  • 多线程访问引发过渡释放问题

以手机淘宝为例,整个生命周期大量使用线程,多线程使用不当引发的崩溃问题占比达到了60%以上
**
1

为了解决多线程崩溃问题或者为了让代码可读性更强可能会严重牺牲应用性能

  • iOS系统API设计很不友好,绝大部分IO、跨进程调用等耗时接口提供的都是同步方法,主线程调用会产生严重性能问题
  • 为了解决多线程崩溃加的锁、信号量等,由于设计不合理,很容易引发卡顿甚至死锁
  • iOS系统API缺乏统一的异步编程模型,Delegate、Callback、同步等杂揉在一起,要写出高性能代码需要付出极大的努力

手机淘宝卡顿问题分布
2

系统API、IO等接口在异步编程上支持并不友好,极易产生性能问题
**

iOS异步编程现状

3

基于Block回调的异步编程方式是目前iOS开发使用最广泛的异步编程方式,下面是使用block回调的异步编程的一个例子:

[NSURLConnection sendAsynchronousRequest:rq queue:nil completionHandler:^(NSURLResponse * _Nullable response, NSData * _Nullable data, NSError * _Nullable connectionError) {
        if (connectionError) {
            if (callback) {
                callback(nil, nil,connectionError);
            }
        }
        else{
            dispatch_async(dispatch_get_global_queue(0, 0), ^{
                NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
                NSString *imageUrl = dict[@"image"];
                [NSURLConnection sendAsynchronousRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:imageUrl]] queue:nil completionHandler:^(NSURLResponse * _Nullable response, NSData * _Nullable data, NSError * _Nullable connectionError) {
                    dispatch_async(dispatch_get_global_queue(0, 0), ^{
                        if (connectionError) {
                            callback(nil, dict,connectionError);
                        }
                        else{
                            UIImage *image = [[UIImage alloc] initWithData:data];
                            if (callback) {
                                (image, dict, nil);
                            }
                        }
                    });
                }];
            });
            
        }
    }];

基于Block回调的异步编程方式有以下缺点:

  • 容易进入"嵌套地狱"
  • 错误处理复杂和冗长
  • 容易忘记调用completion handler
  • 条件执行变得很困难
  • 从互相独立的调用中组合返回结果变得极其困难
  • 在错误的线程中继续执行(如子线程操作UI)

其他语言的异步编程方式

4

kotlin中通过协程实现的异步编程方式,代码简洁,逻辑清晰

5

node.js中通过协程async/await方式实现的可重试异步网络请求

协程不仅打开了异步编程的大门,还提供了大量其他的可能性
**

协程是什么?

6

协程具有以下特征

7

协程的概念在60年代就已经提出,目前在服务端中应用比较广泛,在高并发场景下使用极其合适,可以极大降低单机的线程数,提升单机的连接和处理能力,但是在iOS移动开发中并没有框架支持。

从其他语言的发展来看,基于协程的全新的异步编程方式,是我们解决现有异步编程问题的有效的方式,但是无奈苹果对于Objective-C的支持基本已经停滞,也不指望苹果能够让Objective-C的开发者用上协程,基于我们团队长期对系统底层库和汇编的研究,我们通过汇编和C语言实现了支持Objective-C和Swift协程的完美解决方案coobjc

iOS协程开发框架——coobjc

coobjc是由手机淘宝架构团队推出的能在iOS上使用的协程开发框架,目前支持Objective-C和Swift中使用,我们底层使用汇编和C语言进行开发,上层进行提供了Objective-C和Swift的接口,目前以Apache开源协议进行了开源,开源项目地址https://github.com/alibaba/coobjc

8

coobjc框架设计

  • 最底层是协程内核,包含了栈切换的管理、协程调度器的实现、协程间通信channel的实现等
  • 中间层是基于协程的操作符的包装,目前支持async/await、Generator、Actor等编程模型
  • 最上层是对系统库的协程化扩展,目前基本上覆盖了Foundation和UIKit的所有IO和耗时方法

async/await操作符

9
(引用自:https://dkandalov.github.io/async-await)
上图介绍了await的作用,在协程函数中通过await调用异步方法,当前线程的执行流会立即返回,不会阻塞当前线程的执行,当异步方法执行结束后,await会恢复当前协程函数的执行,这样在协程内部是顺序执行,但是协程不会阻塞当前线程其他代码的执行

  • 创建协程

我们使用co_launch方法创建协程

co_launch(^{
    ...
});

co_launch创建的协程默认在当前线程进行调度

  • await异步方法

在协程中我们使用await方法等待异步方法执行结束,得到异步执行结果


- (void)viewDidLoad{
    ...
        co_launch(^{
            NSData *data = await(downloadDataFromUrl(url));
            UIImage *image = await(imageFromData(data));
            self.imageView.image = image;
        });
}

上述代码将原本需要dispatch_async两次的代码变成了顺序执行,代码更加简洁

  • 使用场景

协程最重要的使用场景就是异步计算(在C#等语言中通过async/await进行处理)。我们先看一个通过传统的callback进行异步I/O的场景:

//从网络异步加载数据
[NSURLSession sharedSession].configuration.requestCachePolicy = NSURLRequestReloadIgnoringCacheData;
    NSURLSessionDownloadTask *task = [[NSURLSession sharedSession] downloadTaskWithURL:url completionHandler:
                                      ^(NSURL *location, NSURLResponse *response, NSError *error) {
                                          if (error) {
                                              return;
                                          }

                                          //在子线程解析数据,并生成图片                                          
                                          dispatch_async(dispatch_get_global_queue(0, 0), ^{
                                              NSData *data = [[NSData alloc] initWithContentsOfURL:location];
                                              UIImage *image = [[UIImage alloc] initWithData:data];
                                              dispatch_async(dispatch_get_main_queue(), ^{
                                                  //调度到主线程显示图片 
                                                  imageView.image = image;
                                              });
                                          });

                                      }];

上面是iOS开发中常见的异步调用方式,我们经常需要在callback中嵌套callback,代码的缩进和逻辑变得越来越复杂,代码可读性和可维护性会随着回调的嵌套层级增长变得越来越差,进入所谓的"callback hell"(嵌套地狱)
同样的异步计算,使用协程可以很直接的表达出来(需要有库提供了满足协程需要的I/O接口):

co_launch(^{
    NSData *data = await(downloadDataFromUrl(url));
    UIImage *image = await(imageFromData(data));
    imageView.image = image;
});

Generator

10

协程的另一个经典的使用场景就是懒计算序列(在C#、Python等语言中通过yield来处理)。这个懒计算序列可以通过顺序执行的代码生成,只有在需要的时候才进行计算:

COSequence *fibonacci = sequence(^{
        yield(@(1));
        int cur = 1;
        int next = 1;
        while(1){
            yield(@(next));
            int tmp = cur + next;
            cur = next;
            next = tmp;
        }
    });

这个代码创建了懒加载的斐波拉契序列,我们可以获取序列的值,通过take或者next:

for(id val in fibonacci){
    NSLog(@"%@", val);
}

id val = [fibonacci next];
NSArray* list = [fibonacci take:5]
  • 传统容器类与Generator的区别

11

  • 创建Generator

我们使用co_sequence创建Generator

COCoroutine *co1 = co_sequence(^{
            int index = 0;
            while(co_isActive()){
                yield_val(@(index));
                index++;
            }
        });

在其他协程中,我们可以调用next方法,获取生成器中的数据

co_launch(^{
            for(int i = 0; i < 10; i++){
                val = [[co1 next] intValue];
            }
        });
  • 使用场景

生成器可以在很多场景中进行使用,比如消息队列、批量下载文件、批量加载缓存等:

int unreadMessageCount = 10;
NSString *userId = @"xxx";
COSequence *messageSequence = sequenceOnBackgroundQueue(@"message_queue", ^{
   //在后台线程执行
    while(1){
        yield(queryOneNewMessageForUserWithId(userId));
    }
});

//主线程更新UI
co(^{
   for(int i = 0; i < unreadMessageCount; i++){
       if(!isQuitCurrentView()){
           displayMessage([messageSequence take]);
       }
   }
});

通过生成器,我们可以把传统的生产者加载数据-》通知消费者模式,变成消费者需要数据-》告诉生产者加载模式,避免了在多线程计算中,需要使用很多共享变量进行状态同步,消除了在某些场景下对于锁和信号量的使用

  • 示例

我们接下来演示一下如何使用Generator进行XML的解析,传统的XML解析方式如下:

12

我们需要设置Delegate,在Delegate中处理所有的解析逻辑

使用Generator解析后,我们的代码逻辑变成了如下方式:

13

我们可以在一个循环里遍历解析了,更加简单方便,尤其对于大的XML文件,我们可以只解析一部分,然后就cancel,这样可以更加节省内存

Actor

Actor的概念来自于Erlang,在AKKA中,可以认为一个Actor就是一个容器,用以存储状态、行为、Mailbox以及子Actor与Supervisor策略。Actor之间并不直接通信,而是通过Mail来互通有无。

14

mailbox: 存储message的队列
Isolated State: actor的状态,内部变量等
message: 类似于OOP的方法调用的参数




Actor模型的执行方式有两个特点:
1. 每个Actor,单线程地依次执行发送给它的消息。
2. 不同的Actor可以同时执行它们的消息。

  • 创建Actor

我们可以使用co_actor_onqueue在指定线程创建actor

CCOActor *actor = co_actor_onqueue(^(CCOActorChan *channel) {
    ...  //定义actor的状态变量
    for(CCOActorMessage *message in channel){
        ...//处理消息
    }
}, q);
  • 给actor发送消息

actor的send方法可以给actor发送消息

CCOActor *actor = co_actor_onqueue(^(CCOActorChan *channel) {
    ...  //定义actor的状态变量
    for(CCOActorMessage *message in channel){
        ...//处理消息
    }
}, q);

// 给actor发送消息
[actor send:@"sadf"];
[actor send:@(1)];
  • 使用场景

现有的面向对象编程设计的思路,很容易在多线程引发的崩溃问题,因为类的方法和属性都是暴露给调用方,调用方可以在任何线程进行调用,但是该线程往往并不是库的提供者设想的线程,就很容易出现崩溃
从理论上来说,我们可以通过合理的设计来让多线程任务执行变得趋于合理,同时通过丰富的文档和示例告诉使用方该如何正确的调用我们设计的接口,但是这种通过依赖人工设计和文档来解决问题并不彻底,终究会因为疏忽而引发新的问题

15

使用Actor编程模型可以帮助我们很好的设计出线程安全的模块,

  • 示例

使用传统的方式,如果我们要实现一个计数器,可以按照如下方式实现:

16

传统的方式通过锁来确保线程安全,那使用Actor,我们可以使用如下方式实现:

COActor *countActor = co_actor_onqueue(get_test_queue(), ^(COActorChan *channel) {
            int count = 0;
            for(COActorMessage *message in channel){
                if([[message stringType] isEqualToString:@"inc"]){
                    count++;
                }
                else if([[message stringType] isEqualToString:@"get"]){
                    message.complete(@(count));
                }
            }
        });

对于上述actor实现的计数器,可以按照如下方式使用:

co_launch(^{
    [countActor sendMessage:@"inc"];
    [countActor sendMessage:@"inc"];
    [countActor sendMessage:@"inc"];
    int currentCount = [await([countActor sendMessage:@"get"]) intValue];
    NSLog(@"count: %d", currentCount);
});
co_launch_onqueue(dispatch_queue_create("counter queue1", NULL), ^{
    [countActor sendMessage:@"inc"];
    [countActor sendMessage:@"inc"];
    [countActor sendMessage:@"inc"];
    [countActor sendMessage:@"inc"];
    int currentCount = [await([countActor sendMessage:@"get"]) intValue];
    NSLog(@"count: %d", currentCount);
});

在两个不同线程中进行调用,完全不用担心线程安全

Swift的支持

通过上层的封装,coobjc完全支持Swift,让我们可以在Swift中提前享用协程。
由于Swift更丰富、更高级的语法支持,coobjc在Swift中使用更优雅,例如:

func test() {
    co_launch {//创建协程
            //异步获取数据
        let resultStr = try await(channel: co_fetchSomething())
        print("result: \(resultStr)")
    }
    
    co_launch {//创建协程
            //异步获取数据
        let result = try await(promise: co_fetchSomethingAsynchronous())
        switch result {
            case .fulfilled(let data):
                print("data: \(String(describing: data))")
                break
            case .rejected(let error):
                print("error: \(error)")
        }
    }
}

使用coobjc的实际案例

我们以GCDFetchFeed开源项目中Feeds流更新的代码为例,演示一下协程的实际使用场景和优势,下面是原始的不使用协程的实现:

- (RACSignal *)fetchAllFeedWithModelArray:(NSMutableArray *)modelArray {
    @weakify(self);
    return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
        @strongify(self);
        //创建并行队列
        dispatch_queue_t fetchFeedQueue = dispatch_queue_create("com.starming.fetchfeed.fetchfeed", DISPATCH_QUEUE_CONCURRENT);
        dispatch_group_t group = dispatch_group_create();
        self.feeds = modelArray;
        for (int i = 0; i < modelArray.count; i++) {
            dispatch_group_enter(group);
            SMFeedModel *feedModel = modelArray[i];
            feedModel.isSync = NO;
            [self GET:feedModel.feedUrl parameters:nil progress:nil success:^(NSURLSessionTask *task, id responseObject) {
                dispatch_async(fetchFeedQueue, ^{
                    @strongify(self);
                    //解析feed
                    self.feeds[i] = [self.feedStore updateFeedModelWithData:responseObject preModel:feedModel];
                    //入库存储
                    SMDB *db = [SMDB shareInstance];
                    @weakify(db);
                    [[db insertWithFeedModel:self.feeds[i]] subscribeNext:^(NSNumber *x) {
                        @strongify(db);
                        SMFeedModel *model = (SMFeedModel *)self.feeds[i];
                        model.fid = [x integerValue];
                        if (model.imageUrl.length > 0) {
                            NSString *fidStr = [x stringValue];
                            db.feedIcons[fidStr] = model.imageUrl;
                        }
                        //插入本地数据库成功后开始sendNext
                        [subscriber sendNext:@(i)];
                        //通知单个完成
                        dispatch_group_leave(group);
                    }];
                    
                });//end dispatch async
                
            } failure:^(NSURLSessionTask *operation, NSError *error) {
                NSLog(@"Error: %@", error);
                dispatch_async(fetchFeedQueue, ^{
                    @strongify(self);
                    [[[SMDB shareInstance] insertWithFeedModel:self.feeds[i]] subscribeNext:^(NSNumber *x) {
                        SMFeedModel *model = (SMFeedModel *)self.feeds[i];
                        model.fid = [x integerValue];
                        dispatch_group_leave(group);
                    }];
                    
                });//end dispatch async
                
            }];
            
        }//end for
        //全完成后执行事件
        dispatch_group_notify(group, dispatch_get_main_queue(), ^{
            [subscriber sendCompleted];
        });
        return nil;
    }];
}

下面是viewDidLoad中对上述方法的调用:

    [UIApplication sharedApplication].networkActivityIndicatorVisible = YES;
    self.fetchingCount = 0; //统计抓取数量
    @weakify(self);
    [[[[[[SMNetManager shareInstance] fetchAllFeedWithModelArray:self.feeds] map:^id(NSNumber *value) {
        @strongify(self);
        NSUInteger index = [value integerValue];
        self.feeds[index] = [SMNetManager shareInstance].feeds[index];
        return self.feeds[index];
    }] doCompleted:^{
        //抓完所有的feeds
        @strongify(self);
        NSLog(@"fetch complete");
        //完成置为默认状态
        self.tbHeaderLabel.text = @"";
        self.tableView.tableHeaderView = [[UIView alloc] init];
        self.fetchingCount = 0;
        //下拉刷新关闭
        [self.tableView.mj_header endRefreshing];
        //更新列表
        [self.tableView reloadData];
        //检查是否需要增加源
        if ([SMFeedStore defaultFeeds].count > self.feeds.count) {
            self.feeds = [SMFeedStore defaultFeeds];
            [self fetchAllFeeds];
        }
        //缓存未缓存的页面
        [self cacheFeedItems];
    }] deliverOn:[RACScheduler mainThreadScheduler]] subscribeNext:^(SMFeedModel *feedModel) {
        //抓完一个
        @strongify(self);
        self.tableView.tableHeaderView = self.tbHeaderView;
        //显示抓取状态
        self.fetchingCount += 1;
        self.tbHeaderLabel.text = [NSString stringWithFormat:@"正在获取%@...(%lu/%lu)",feedModel.title,(unsigned long)self.fetchingCount,(unsigned long)self.feeds.count];
        feedModel.isSync = YES;
        [self.tableView reloadData];
    }];

上述代码无论从可读性还是简洁性上都比较差,下面我们看一下使用协程改造以后的代码:

- (SMFeedModel*)co_fetchFeedModelWithUrl:(SMFeedModel*)feedModel{
    feedModel.isSync = NO;
    id response = await([self co_GET:feedModel.feedUrl parameters:nil]);
    if (response) {
        SMFeedModel *resultModel = await([self co_updateFeedModelWithData:response preModel:feedModel]);
        int fid = [[SMDB shareInstance] co_insertWithFeedModel:resultModel];
        resultModel.fid = fid;
        if (resultModel.imageUrl.length > 0) {
            NSString *fidStr = [@(fid) stringValue];
            [SMDB shareInstance].feedIcons[fidStr] = resultModel.imageUrl;
        }
        return resultModel;
    }
    int fid = [[SMDB shareInstance] co_insertWithFeedModel:feedModel];
    feedModel.fid = fid;
    return nil;
}

下面是viewDidLoad中使用协程调用该接口的地方:

co_launch(^{
    for (NSUInteger index = 0; index < self.feeds.count; index++) {
        SMFeedModel *model = self.feeds[index];
        self.tableView.tableHeaderView = self.tbHeaderView;
        //显示抓取状态
        self.tbHeaderLabel.text = [NSString stringWithFormat:@"正在获取%@...(%lu/%lu)",model.title,(unsigned long)(index + 1),(unsigned long)self.feeds.count];
        model.isSync = YES;
        //协程化加载数据
        SMFeedModel *resultMode = [[SMNetManager shareInstance] co_fetchFeedModelWithUrl:model];
        if (resultMode) {
            self.feeds[index] = resultMode;
            [self.tableView reloadData];
        }
    }
    self.tbHeaderLabel.text = @"";
    self.tableView.tableHeaderView = [[UIView alloc] init];
    self.fetchingCount = 0;
    //下拉刷新关闭
    [self.tableView.mj_header endRefreshing];
    //更新列表
    [self.tableView reloadData];
    //检查是否需要增加源
    [self cacheFeedItems];
});

协程化改造之后的代码,变得更加简单易懂,不易出错

协程的优势

  • 简明

    • 概念少:只有很少的几个操作符,相比响应式几十个操作符,简直不能再简单了
    • 原理简单: 协程的实现原理很简单,整个协程库只有几千行代码
  • 易用

    • 使用简单:它的使用方式比GCD还要简单,接口很少
    • 改造方便:现有代码只需要进行很少的改动就可以协程化,同时我们针对系统库提供了大量协程化接口
  • 清晰

    • 同步写异步逻辑:同步顺序方式写代码是人类最容易接受的方式,这可以极大的减少出错的概率
    • 可读性高: 使用协程方式编写的代码比block嵌套写出来的代码可读性要高很多
  • 性能

    • 调度性能更快:协程本身不需要进行内核级线程的切换,调度性能快,即使创建上万个协程也毫无压力
    • 减少卡顿卡死: 协程的使用以帮助开发减少锁、信号量的滥用,通过封装会引起阻塞的IO等协程接口,可以从根源上减少卡顿、卡死,提升应用整体的性能

总结

程序是写来给人读的,只会偶尔让机器执行一下
——Abelson and Sussman

  • 基于协程实现的编程范式能够帮助开发者编写出更加优美、健壮、可读性更强的代码
  • 协程可以帮助我们在编写并发代码的过程中减少线程和锁的使用,提升应用的性能和稳定性

参考文档

淘宝基础平台团队正在举行2019实习生(2020年毕业)和社招招聘,岗位有iOSAndroid客户端开发工程师、Java研发工程师、C/C++研发工程师、前端开发工程师、算法工程师,欢迎投递简历至junzhan.yzw@taobao.com
如果你想更详细了解淘宝基础平台团队,欢迎观看团队介绍视频
更多淘宝基础平台团队的技术分享,可关注淘宝技术微信公众号AlibabaMTT

目录
相关文章
|
29天前
|
PHP 调度 开发者
探索PHP新特性:协程编程的崛起
PHP作为一种流行的服务器端脚本语言,近年来不断发展壮大。本文将重点探讨PHP中新兴的技术领域——协程编程,介绍其原理、优势以及在实际项目中的应用场景,帮助读者更好地理解并运用这一技术。
|
30天前
|
并行计算 Java 调度
C/C++协程编程:解锁并发编程新纪元
C/C++协程编程:解锁并发编程新纪元
92 0
|
2月前
|
调度 Python
什么是Python中的协程(Coroutine)?如何使用`async`和`await`进行协程编程?
什么是Python中的协程(Coroutine)?如何使用`async`和`await`进行协程编程?
26 0
|
4月前
|
存储 前端开发 rax
不一样的编程方式 —— 协程(设计原理与汇编实现)
不一样的编程方式 —— 协程(设计原理与汇编实现)
|
4月前
|
程序员 调度 C#
协程是什么?为何说协程具有同步的编程方式又具有异步的性能?
协程是什么?为何说协程具有同步的编程方式又具有异步的性能?
73 0
|
Python
Python编程:asyncio协程编程
Python编程:asyncio协程编程
|
Python
Python编程:Coroutine协程之生产者消费者模型
Python编程:Coroutine协程之生产者消费者模型
|
数据采集 消息中间件 Python
Python编程:协程coroutine
Python编程:协程coroutine
154 0
|
IDE Java API
Java协程编程之Loom项目尝鲜
Java协程项目Loom(因为项目还在开发阶段,OpenJDK给出的官网https://openjdk.java.net/projects/loom中只有少量Loom项目相关的信息)已经在2018年之前立项,目前已经发布过基于JDK17编译和JDK18编译等早期版本,笔者在下载Loom早期版本的时候只找到JDK18编译的版本。由于该JDK版本过高,目前可以使用主流IDE导入Loom-JDK-18+9进行代码高亮和语法提醒,暂时找不到方法进行编译,暂时使用该JDK执行目录下的的javac命令脚本进行编译,使用java命令脚本运行。
736 0
Java协程编程之Loom项目尝鲜
|
Python
Python编程:协程async和await
Python编程:协程async和await
181 0