初探coobjc源码

文丨清枫

协程

今年年初阿里开源的coobjc,可谓是另iOS开发者们大开眼界。coobjc这个名字,以co开头,其实可以分解为co-objcco就是coroutine(协程)单词缩写。
协程子例程一样,协程(coroutine)也是一种程序组件。相对子例程而言,协程更为一般和灵活,但在实践中使用没有子例程那样广泛。协程源自 SimulaModula-2 语言,但也有其他语言支持。
协程不是进程或线程,其执行过程更类似于子例程,或者说不带返回值的函数调用
一个程序可以包含多个协程,可以对比与一个进程包含多个线程,因而下面我们来比较协程和线程。我们知道多个线程相对独立,有自己的上下文,切换受系统控制;而协程也相对独立,有自己的上下文,但是其切换由自己控制,由当前协程切换到其他协程由当前协程来控制。
协程的概念在60年代就已经提出,目前在服务端中应用比较广泛,在高并发场景下使用极其合适,可以极大降低单机的线程数,提升单机的连接和处理能力,但是在移动研发中,iOSandroid目前都不支持协程的使用。

coobjc实现了什么(来自官方文档)

这个库为 Objective-CSwift 提供了协程功能。coobjc支持 awaitgeneratoractor model,接口参考了 C#JavascriptKotlin 中的很多设计。还提供了 cokit 库FoundationUIKit 中的部分 API 提供了协程化支持,包括 NSFileManager , JSON , NSData , UIImage 等。coobjc 也提供了元组的支持。

coobjc 是由手机淘宝架构团队推出的能在 iOS 上使用的协程开发框架,目前支持 Objective-CSwift 中使用,底层使用汇编和 C 语言进行开发,上层进行提供了 Objective-CSwift 的接口,目前以 Apache 开源协议进行了开源。

iOS异步编程的问题

基于 Block 的异步编程回调是目前 iOS 使用最广泛的异步编程方式,iOS 系统提供的 GCD 库让异步开发变得很简单方便,但是基于这种编程方式的缺点也有很多,主要有以下几点:

  • 容易进入"嵌套地狱"
  • 错误处理复杂和冗长
  • 容易忘记调用 completion handler
  • 条件执行变得很困难
  • 从互相独立的调用中组合返回结果变得极其困难
  • 在错误的线程中继续执行
  • 难以定位原因的多线程崩溃
  • 锁和信号量滥用带来的卡顿、卡死
    上述问题反应到线上应用本身就会出现大量的多线程崩溃

解决方案

上述问题在很多系统和语言中都会遇到,解决问题的标准方式就是使用协程。这里不介绍太多的理论,简单说协程就是对基础函数的扩展,可以让函数异步执行的时候挂起然后返回值。协程可以用来实现 generator ,异步模型以及其他强大的能力。

Kotlin 是这两年由 JetBrains 推出的支持现代多平台应用的静态编程语言,支持 JVMJavascript ,目前也可以在iOS上执行,这两年在开发者社区中也是比较火。
Kotlin 语言中基于协程的 async/awaitgenerator/yield 等异步化技术都已经成了语法标配,Kotlin 协程官方文档

官方文档

coobjc的设计


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

核心实现原理

协程的核心思想是控制调用栈的主动让出和恢复。一般的协程实现都会提供两个重要的操作:
Yield:是让出cpu的意思,它会中断当前的执行,回到上一次Resume的地方。
Resume:继续协程的运行。执行Resume后,回到上一次协程Yield的地方。
基于线程的代码执行时候,是没法做出暂停操作的,现在要做的事情就是要代码执行能够暂停,还能够再恢复。 基本上代码执行都是一种基于调用栈的模型,所以如果能把当前调用栈上的状态都保存下来,然后再能从缓存中恢复,那就能够实现yieldresume
实现这样操作有几种方法呢?
第一种:利用glibcucontext组件(云风的库)。
第二种:使用汇编代码来切换上下文(实现c协程),原理同ucontext
第三种:利用C语言语法switch-case的奇淫技巧来实现(Protothreads)。
第四种:利用了 C 语言的 setjmplongjmp
第五种:利用编译器支持语法糖。

上述第三种和第四种只是能过做到跳转,但是没法保存调用栈上的状态,看起来基本上不能算是实现了协程,只能算做做demo,第五种除非官方支持,否则自行改写编译器通用性很差。而第一种方案的 ucontextiOS上是废弃了的,不能使用。coobjc使用的是第二种方案,自己用汇编模拟一下 ucontext

coobjc的实现代码

coroutine_context.h文件中声明的方法:

extern int coroutine_getcontext (coroutine_ucontext_t *__ucp);
extern int coroutine_setcontext (coroutine_ucontext_t *__ucp);
extern int coroutine_begin (coroutine_ucontext_t *__ucp);
extern void coroutine_makecontext (coroutine_ucontext_t *__ucp, IMP func, void *arg, void *stackTop);

原有的C协程ucontext(维基百科示例代码)

#include <stdio.h>
#include <ucontext.h>
#include <unistd.h>
 
int main(int argc, const char *argv[]){
    ucontext_t context;
    getcontext(&context);
    puts("Hello world");
    sleep(1);
    setcontext(&context);
    return 0;
}

可改写成:

#import <coobjc/coroutine_context.h>

int main(int argc, const char *argv[]) {
    coroutine_ucontext_t context;
    coroutine_getcontext(&context);
    puts("Hello world");
    sleep(1);
    coroutine_setcontext(&context);
    return 0;
}

结果同样是不断输出以下内容:

Hello world
Hello world
Hello world
Hello world
...

协程结构的设计

coroutine.h文件中看到如下结构:

/**
     The structure store coroutine's context data.
     */
    struct coroutine {
        coroutine_func entry;                   // Process entry.
        void *userdata;                         // Userdata.
        coroutine_func userdata_dispose;        // Userdata's dispose action.
        void *context;                          // Coroutine's Call stack data.
        void *pre_context;                      // Coroutine's source process's Call stack data.
        int status;                             // Coroutine's running status.
        uint32_t stack_size;                    // Coroutine's stack size
        void *stack_memory;                     // Coroutine's stack memory address.
        void *stack_top;                    // Coroutine's stack top address.
        struct coroutine_scheduler *scheduler;  // The pointer to the scheduler.
        
        struct coroutine *prev;
        struct coroutine *next;
        
        void *autoreleasepage;                  // If enable autorelease, the custom autoreleasepage.
        void *chan_alt;                         // If blocking by a channel, record the alt
        bool is_cancelled;                      // The coroutine is cancelled
        int8_t   is_scheduler;                  // The coroutine is a scheduler.
    };
    typedef struct coroutine coroutine_t;
    /**
     Define the linked list of scheduler's queue.
     */
    struct coroutine_list {
        coroutine_t *head;
        coroutine_t *tail;
    };
    typedef struct coroutine_list coroutine_list_t;

从以上结构我们不难看出,结构体的内容包含了一个协程的所在状态和所所持有的信息。协程队列的增减是通过链表结构实现。

    /**
     Define the scheduler.
     One thread own one scheduler, all coroutine run this thread shares it.
     */
    struct coroutine_scheduler {
        coroutine_t         *main_coroutine;
        coroutine_t         *running_coroutine;
        coroutine_list_t     coroutine_queue;
    };
    typedef struct coroutine_scheduler coroutine_scheduler_t;

上面的结构是管理每个协程的调度器结构。

    /**
     Close and free a coroutine if dead.

     @param co coroutine object
     */
    void coroutine_close_ifdead(coroutine_t *co);
    
    /**
     Add coroutine to scheduler, and resume the specified coroutine whatever.
     */
    void coroutine_resume(coroutine_t *co);
    
    /**
     Add coroutine to scheduler, and resume the specified coroutine if idle.
     */
    void coroutine_add(coroutine_t *co);
    
    /**
     Yield the specified coroutine now.
     */
    void coroutine_yield(coroutine_t *co);

上面可以看到协程的生命周期。

使用coobjc

async/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 两次的代码变成了顺序执行,代码更加简洁

  • 错误处理

在协程中,我们所有的方法都是直接返回值的,并没有返回错误,我们在执行过程中的错误是通过 co_getError() 获取的,比如我们有以下从网络获取数据的接口,在失败的时候, promise 会 reject:error

- (COPromise*)co_GET:(NSString*)url
  parameters:(NSDictionary*)parameters{
    COPromise *promise = [COPromise promise];
    [self GET:url parameters:parameters progress:nil success:^(NSURLSessionDataTask * _Nonnull task, id  _Nullable responseObject) {
        [promise fulfill:responseObject];
    } failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
        [promise reject:error];
    }];
    return promise;
}

那我们在协程中可以如下使用:

co_launch(^{
    id response = await([self co_GET:feedModel.feedUrl parameters:nil]);
    if(co_getError()){
        //处理错误信息
    }
    ...
});

生成器

  • 创建生成器

我们使用 co_sequence 创建生成器

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]);
       }
   }
});

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

Actor

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

  • 创建 actor

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

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

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

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

// 给actor发送消息
[actor send:@"sadf"];
[actor send:@(1)];

元组

  • 创建元组

使用 co_tuple 方法来创建元组

COTuple *tup = co_tuple(nil, @10, @"abc");
NSAssert(tup[0] == nil, @"tup[0] is wrong");
NSAssert([tup[1] intValue] == 10, @"tup[1] is wrong");
NSAssert([tup[2] isEqualToString:@"abc"], @"tup[2] is wrong");

可以在元组中存储任何数据

  • 元组取值

可以使用 co_unpack 方法从元组中取值

id val0;
NSNumber *number = nil;
NSString *str = nil;
co_unpack(&val0, &number, &str) = co_tuple(nil, @10, @"abc");
NSAssert(val0 == nil, @"val0 is wrong");
NSAssert([number intValue] == 10, @"number is wrong");
NSAssert([str isEqualToString:@"abc"], @"str is wrong");

co_unpack(&val0, &number, &str) = co_tuple(nil, @10, @"abc", @10, @"abc");
NSAssert(val0 == nil, @"val0 is wrong");
NSAssert([number intValue] == 10, @"number is wrong");
NSAssert([str isEqualToString:@"abc"], @"str is wrong");

co_unpack(&val0, &number, &str, &number, &str) = co_tuple(nil, @10, @"abc");
NSAssert(val0 == nil, @"val0 is wrong");
NSAssert([number intValue] == 10, @"number is wrong");
NSAssert([str isEqualToString:@"abc"], @"str is wrong");

NSString *str1;

co_unpack(nil, nil, &str1) = co_tuple(nil, @10, @"abc");
NSAssert([str1 isEqualToString:@"abc"], @"str1 is wrong");
  • 在协程中使用元组

首先创建一个 promise 来处理元组里的值

COPromise<COTuple*>*
cotest_loadContentFromFile(NSString *filePath){
    return [COPromise promise:^(COPromiseFullfill  _Nonnull resolve, COPromiseReject  _Nonnull reject) {
        if ([[NSFileManager defaultManager] fileExistsAtPath:filePath]) {
            NSData *data = [[NSData alloc] initWithContentsOfFile:filePath];
            resolve(co_tuple(filePath, data, nil));
        }
        else{
            NSError *error = [NSError errorWithDomain:@"fileNotFound" code:-1 userInfo:nil];
            resolve(co_tuple(filePath, nil, error));
        }
    }];
}

然后,你可以像下面这样获取元组里的值:

co_launch(^{
    NSString *tmpFilePath = nil;
    NSData *data = nil;
    NSError *error = nil;
    co_unpack(&tmpFilePath, &data, &error) = await(cotest_loadContentFromFile(filePath));
    XCTAssert([tmpFilePath isEqualToString:filePath], @"file path is wrong");
    XCTAssert(data.length > 0, @"data is wrong");
    XCTAssert(error == nil, @"error is wrong");
});

使用元组你可以从 await 返回值中获取多个值

协程的优点

  • 简明
    • 概念少:只有很少的几个操作符,相比响应式几十个操作符,简直不能再简单了
    • 原理简单: 协程的实现原理很简单,整个协程库只有几千行代码
  • 易用
    • 使用简单:它的使用方式比 GCD 还要简单,接口很少
    • 改造方便:现有代码只需要进行很少的改动就可以协程化,同时我们针对系统库提供了大量协程化接口
  • 清晰
    • 同步写异步逻辑:同步顺序方式写代码是人类最容易接受的方式,这可以极大的减少出错的概率
    • 可读性高: 使用协程方式编写的代码比 block 嵌套写出来的代码可读性要高很多
  • 性能
    • 调度性能更快:协程本身不需要进行内核级线程的切换,调度性能快,即使创建上万个协程也毫无压力
    • 减少卡顿卡死: 协程的使用以帮助开发减少锁、信号量的滥用,通过封装会引起阻塞的 IO 等协程接口,可以从根源上减少卡顿、卡死,提升应用整体的性能

参考文献

官方文档
刚刚,阿里开源 iOS 协程开发框架 coobjc
阿里开源 iOS 协程开发框架coobjc源码分析

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

推荐阅读更多精彩内容