GXiOSiOS面试题

探究 iOS 协程(coobjc)

2019-11-01  本文已影响0人  sidiWang

一.什么是协程

介绍

首先引用维基百科的一段介绍

协程是计算机程序的一类组件,推广了协作式多任务子程序,允许执行被挂起与被恢复。相对子例程而言,协程更为一般和灵活,但在实践中使用没有子例程那样广泛。协程源自SimulaModula-2语言,但也有其他语言支持。协程更适合于用来实现彼此熟悉的程序组件,如协作式多任务异常处理事件循环迭代器无限列表管道

根据高德纳的说法, 马尔文·康威于1958年发明了术语coroutine并用于构建汇编程序[1] [2] 协程最初在1963年被提出。[2]

上面提到的「子例程」我们可以简单理解为函数调用。维基百科还列出了函数调用和协程的区别,这里我总结了一下,具体如下:

简单来说,协程(coroutine)是一种程序运行的方式,可以理解成 协作的线程协作的函数 。协程既可以用单线程实现,也可以用多线程实现。前者是一种特殊的子例程,后者是一种特殊的线程。

历史

徐宥博士在他的博客里有提到:

虽然协程是伴随着高级语言诞生的,它却没有能像子过程一样成为通用编程语言的基本元素。

从 1963 年首次提出到上个世纪九十年代,我们在 ALOGL, Pascal, C, FORTRAN 等主流的命令式编程语言中都没有看到原生的协程支持。协程只稀疏地出现在 Simula,Modular-2 (Pascal 升级版) 和 Smalltalk 等相对小众的语言中。协程作为一个比子进程更加通用的概念,在实际编程却没有取代子进程,这一点不得不说是出乎意外的。如果我们结合当时的程序设计思想看,这一点又是意料之中的:协程是不符合那个时代所崇尚的“自顶向下”的程序设计思想的,自然也就不会成为当时主流的命令式编程语言 (imperative programming) 的一部分。

但是因为硬件性能的提升、多线程等开始普及,协程又重回历史舞台大放异彩。

说一说我理解的协程

通俗一点讲,协程就是可以挂起(暂停)任意函数的执行,这个挂起操作是由开发者来完成的,并且你可以在你想恢复的时候恢复它。它跟线程最大的区别在于线程一旦开始执行,从任务的角度来看,就不会被暂停,直到任务结束这个过程都是连续的,线程之间是抢占式的调度,因此也不存在协作问题。这只是协程提供的最基本的能力,基于这个能力我们可以做很多事情。看起来其实概念很简单,但是应用起来还是比较复杂的。

二.大前端协程的发展

前端

早在2015年,JavaScript 推出 ECMAScript 6 标准的时候,就引入了协程的编程方式,我们可以看一个例子:
对于传统的网络请求,应该是下面这个样子

ajax({
            method: 'POST',
            url: url,
            success: function (data) {
                  //解析 json
                 data.json(function(json) {
                       //在这里对获取的数据进行操作.....

                 })
                   
            })

这种异步编程方式被称之为「回调地狱」。

为了解决「回调地狱」的问题,ECMAScript 6 引入了几种异步编程方式。

Promise

Promise 从字面上来讲,就代表一种承诺。从语法上来讲,它是一个对象。简单来说,Promise 就是一个容器,里面保存着某个未来才会结束的事件。Promise 内部有三种状态:pending(等待),fulfiled(成功),rejected(失败)。Promise 的状态只会受异步操作的影响,并且一旦状态改变之后,就不会在变。Promise 对象的状态改变,只有两种可能:从 pending 变为 fulfilled 和从 pending 变为 rejected
来看一看 Promise 的构造方法:

const promise = new Promise(function(resolve, reject) {
  // ... some code

  if (/* 异步操作成功 */){
    resolve(value);
  } else {
    reject(error);
  }
});

Promise 构造函数接受一个函数作为参数,该函数的两个参数分别是 resolverejectresolve 函数的作用是,将 Promise 对象的状态从 pending 变为 resolved,在异步操作成功时调用,并将异步操作的结果,作为参数传递出去;reject 函数的作用是,将 Promise 对象的状态从从 pending 变为 rejected,在异步操作失败时调用,并将异步操作报出的错误,作为参数传递出去。
然后我们可以指定 thencatch 来分别接收 resolvedreject 回调。

getJSON("/posts.json")
.then(result => {···})
.catch(error => {···})

then 所指定的函数中还可以返回 Promise 对象,相当于一个异步操作里面做了另一个异步操作。

getJSON("/post/1.json").then(function(post) {
  return getJSON(post.commentURL);
}).then(function (comments) {
  console.log("resolved: ", comments);
}, function (err){
  console.log("rejected: ", err);
});

所以我们上面提到的网络请求利用 Promise 最终可以写成这样:

fetch(url).then(function(response) {
  return response.json()
}).then(function (json) {
  //处理json
}).catch(function (err){
  console.log("rejected: ", err);
});

可以看到,通过 Promise 的改造后,嵌套的异步编程变得很清晰了。

Generator

上面讲了异步的一种新的解决方案 Promise,但它并不是基于协程的,只是内部对回调函数做了封装。下面我们会介绍一种真正基于协程的异步编程解决方案 Generator

function* gen(x) {
  var y = yield x + 2;
  return y;
}

var g = gen(1);
g.next() // { value: 3, done: false }
g.next() // { value: undefined, done: true }

ES6 中,用 function* 表示协程函数。调用gen(1)的时候函数不会立即执行,而是返回一个遍历器(提供 next 方法来遍历)。调用 g.next() 会真正执行函数, 当遇到 yield 的时候,主函数会出让控制权,转而执行 yield 后面的语句。g.next() 的返回值是一个对象,value 代表 yield 后面表达式的返回值,done 代表当前的 Generator 函数是否执行完毕。
Generator 的执行过程可以用下图表示:

Generator 执行过程

上面的示例 Generator 只是处理了一个同步操作,但其实 Generator 的威力是在处理异步上,下面来看一下一个 Generator 在异步情况下的处理。由于异步操作的特殊性,在 yield 切换到异步操作的时候,并不知道具体什么时候返回,所以在这里需要借助 Thunk 函数和递归来实现异步 Generator 的自动执行 。JS 有一个叫做 co 的库可以让异步 Generator 自动执行。
定义一个 Generator 函数:

var gen = function* () {
  var f1 = yield readFile('/etc/fstab');
  var f2 = yield readFile('/etc/shells');
  console.log(f1.toString());
  console.log(f2.toString());
};

利用 co 可以自动执行异步 Generator

var co = require('co');
co(gen);

co 会返回一个 Promise 对象,用于订阅函数执行完毕的回调:

co(gen).then(function (){
  console.log('Generator 函数执行完成');
});

async / await

ES2017 标准引入了 async 函数,使得异步操作更加方便。我们上面用 Generator 实现的读取文件操作,转化成 async 函数就是这样:

const asyncReadFile = async function () {
  const f1 = await readFile('/etc/fstab');
  const f2 = await readFile('/etc/shells');
  console.log(f1.toString());
  console.log(f2.toString());
};

其实 async / await 函数就是 generator 的语法糖。把 * 号换成了 async,把 yield 换成了 awaitGenerator 的执行需要执行器,所以才有了上面提到的 co 库。而 async 函数自带执行器,所以调用它可以像调用普通函数那样:

asyncReadFile()

下面用 async 函数改写一个普通的网络请求场景:

async function loadData() {
  let response = await fetch(url)
  let responseJson = await response.json()
  //接下来对 json 进行操作.....
}

await 后面一般是一个 Promise 对象,也可以跟基础类型。如果是基础类型 await 会立刻返回。

iOS

对于 iOS 来说,苹果一直没有推出协程的 API,iOS 的异步编程一直都是基于 Block 实现。可以用一张图诠释 iOS 的异步编程现状:


iOS 的异步编程现状.png

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

而就在今年的早些时候,一篇名为「刚刚,阿里开源 iOS 协程开发框架 coobjc!」的文章引爆了朋友圈,阿里推出了 iOS 上的协程框架 「coobjc」

先来看一下这个库的架构: coobjc 框架结构.png

以下是这个库的核心能力:

  • 提供了类似 C#Javascript 语言中的 Async/Await 编程方式支持,在协程中通过调用 await 方法即可同步得到异步方法的执行结果,非常适合IO、网络等异步耗时调用的同步顺序执行改造。

  • 提供了类似 Kotlin 中的 Generator 功能,用于懒计算生成序列化数据,非常适合多线程可中断的序列化数据生成和访问。

  • 提供了 Actor Model 的实现,基于 Actor Model ,开发者可以开发出更加线程安全的模块,避免由于直接函数调用引发的各种多线程崩溃问题。

  • 提供了元组的支持,通过元组 Objective-C 开发者可以享受到类似 Python 语言中多值返回的好处。

还对 Foundation 的一些基础库做了扩展:

  • 提供了对 NSArray、NSDictionary 等容器库的协程化扩展,用于解决序列化和反序列化过程中的异步调用问题。

  • 提供了对 NSData、NSString、UIImage 等数据对象的协程化扩展,用于解决读写IO过程中的异步调用问题。

  • 提供了对 NSURLConnectionNSURLSession 的协程化扩展,用于解决网络异步请求过程中的异步调用问题。

  • 提供了对 NSKeyedArchieve、NSJSONSerialization等解析库的扩展,用于解决解析过程中的异步调用问题。

官方也提供了使用示例:

用 NSURLSession 写了一个最普通的网络请求: 普通网络请求.png
经过协程改造后的网络请求: 协程改造后的网络请求.png

coobjc 中,也可以找到上面提到的 ES6 中的 API:

COPromise

COPromise 的用法同 ES6 Promiese 对象的用法基本一样:

- (void)testCoobjcPromise {
    COPromise *promise = [COPromise promise:^(COPromiseFulfill  _Nonnull fullfill, COPromiseReject  _Nonnull reject) {
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
            fullfill(@"异步任务结束");
        });
    } onQueue:dispatch_get_global_queue(0, 0)];
    [promise then:^id _Nullable(id  _Nullable value) {
        NSLog(@"%@",value);
        return nil;
    }];
}

在延迟三秒之后,控制台输出了我们在 Promise 内部 fullfill 回调中传入的字符串 “异步任务结束” 。

COGenerator

COGenerator 也类似,只是写法上略有不同:

- (void)testGenerator {
    COGenerator *generator = [[COGenerator alloc] initWithBlock:^{
        int index = 0;
        while(co_isActive()){
            yield_val(@(index));
            index++;
        }
    } onQueue:dispatch_get_global_queue(0, 0) stackSize:1024];
    co_launch(^{
        for(int i = 0; i < 10; i++){
            int val = [[generator next] intValue];
            NSLog(@"generator______value:%d",val);
        }
    });
}

COChan

COChan 可以理解为一个管道,管道两边分别是消息的发送者和接收者。原型是基于CSP并发模型
实际上 COChan 就是一个阻塞的消息队列。
我们可以通过以下的方法创建一个 COChan

COChan *chan = [COChan chanWithBuffCount:1];

buffCount 表示该 chan 可以容纳的消息数量,因为在阻塞的条件下面 chan 内部的消息会堆积。
创建好 chan 之后,可以通过 sendreceive 方法发送和接收 chan 的消息。sendreceive 有两种,一种是阻塞式的 sendreceive,一种是非阻塞式的 send_nonblockreceive_nonblock。以下是一些 send 和 receive 需要注意的点:

Await

与 ES6 不同的是, coobjc 的 await 方法里面只能处理 COPromiseCOChan 类型的对象:

- (COPromise *)Promise {
    COPromise *promise = [COPromise promise:^(COPromiseFulfill  _Nonnull fullfill, COPromiseReject  _Nonnull reject) {
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
            fullfill(@"异步任务结束");
        });
    } onQueue:dispatch_get_global_queue(0, 0)];
    return promise;
}
-(void)testAwait {
    co_launch(^{
        id result = await([self Promise]);
        NSLog(@"%@", result);
    });
}

在这里我们构造了一个 COPromise 对象,并在内部模拟了一个异步操作,然后我们把这个COPromise 对象传到了 await 方法里,在 await 方法的返回值里我们得到了这个 COPromise 对象的输出。
注意: coobjc 协程上的任务都需要执行在 co_launch 的回调中。
co_await 执行过程:

co_await流程图

三.优势

介绍完了上面几种协程的 API,那么相对传统的基于子例程的开发方式,协程到底带来了什么优势?

异步操作的同步化表达

假设我们有3个异步任务需要同步执行,并且它们之间互相依赖,那么基于传统的回调函数是下面这样:

- (void)testNormalAsyncFunc {
    @weakify(self);
    [self asyncTask:^(NSInteger number) {
        @strongify(self);
        [self asyncTask:^(NSInteger number) {
            @strongify(self);
            NSInteger num = number + 1;
            [self asyncTask:^(NSInteger number) {
                @strongify(self);
                NSLog(@"testNormalAsyncFunc_____%ld",(long)number);
            } withNumber:num];
        } withNumber:number];
    } withNumber:1];
}

- (void)asyncTask:(void(^)(NSInteger number))callBack withNumber:(NSInteger)number {
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        if (callBack) {
            callBack(number);
        }
    });
}

传统的回调函数在代码上阅读不是特别友好,并且需要处理多级 Block 嵌套过程中循环引用的问题。在这里我是直接忽略调了错误处理,真实的环境中错误处理不容忽视,那么在多级回调中就会变得特别复杂,这也是文章一开始说的回调地狱。
下面,我们看下经过协程改造后的代码:

- (void)testCORoutineAsyncFunc {
    co_launch(^{
        NSNumber *num = await([self promiseWithNumber:@(1)]);
//        NSError *error = co_getError(); 如果有错误,可以这样获取
        num = await([self promiseWithNumber:@(num.integerValue + 1)]);
        num = await([self promiseWithNumber:@(num.integerValue + 1)]);
        NSLog(@"testCORoutineAsyncFunc______%@",num);
    });
}

- (COPromise *)promiseWithNumber:(NSNumber *)number {
    COPromise *promise = [COPromise promise:^(COPromiseFulfill  _Nonnull fullfill, COPromiseReject  _Nonnull reject) {
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
            fullfill(number);
//            reject(error);  // 如果有错误,回调到上层
        });
    } onQueue:dispatch_get_global_queue(0, 0)];
    return promise;
}

可以看到,异步流程非常清晰,就像写同步代码一样。另外,如果发生错误,在 Promise 内部会调用 reject 回调将错误回调出去,在 await 之后可以调用 co_getError 获取到错误。

对于 IO 的封装

iOS 系统 API 设计很不友好,绝大部分 IO、跨进程调用等耗时接口提供的都是同步方法,主线程调用会产生严重性能问题。coobjc 封装了基本所有的 IO API,让 IO 拥有了协程调度的能力。

//从网络异步加载数据
[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;
                                              });
                                          });

                                      }];

协程改造后:

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

懒加载序列

懒加载序列也是协程的一个经典用法。下面我们利用 COGenerator 生一个懒加载的斐波那契序列:

- (void)testFibonacciLazySequence {
    COGenerator *fibonacci = [[COGenerator alloc] initWithBlock:^{
        yield_val(@(1));
        int cur = 1;
        int next = 1;
        while(co_isActive()){
            yield(@(next));
            int temp = cur + next;
            cur = next;
            next = temp;
        }
    } onQueue:dispatch_get_global_queue(0, 0) stackSize:1024];
    co_launch(^{
        for(int i = 0; i < 10; i++){
            int val = [[fibonacci next] intValue];
            NSLog(@"fibonacciLazySequence______value:%d",val);
        }
    });
}

利用 COGenerator 我们生成了一个懒加载的斐波那契数列并打印了数列的前10个元素,由于是懒加载序列,只有在调用 next 方法的时候才会真正在内存中生成这个元素。并且,生成器的实现是线程安全的,因为它们都是在单线程上运行,数据在生成器中生成,然后在另一条协程上使用,期间不需要加任何锁。而使用传统容器需要注意线程安全问题并且容易引发 crash。
下面一张图里诠释了 Generator 和传统容器的区别:

Generator和传统容器的区别
传统的数组,为了保障线程安全,读写操作必须放到一个同步队列中。而 generator 不需要,generator 可以在一个线程生成数据,另外一个线程消费数据。

Actor Model

race condition

大家应该都听说过进程线程,也都知道进程线程的概念。简单概括就是:
对操作系统来说,线程是最小的执行单元,进程是最小的资源管理单元。

进程和线程都是由操作系统调度和管理的,开发者并没有权限。现在主流的操作系统下,线程都是抢占式的。每个线程有特定的时间片,当线程时间片耗尽,操作系统就会让线程暂停转而去执行其它线程。这就造成了在多线程下,线程与线程之间会出现 race condition。举一个最常见的 i++ 的例子:

i++ 操作在底层其实是有三步:

i++步骤

在多线程情况下,就有可能出现如图所示的情况。
为了避免这个问题,我们需要对出现 race condition 的资源加锁。但是锁会带来以下问题:

以上涉及到的任何一点,都是非常耗费性能的操作。

Actor

为了避免频繁的线程加锁和线程切换,我们引入了一种新的并发模型:
Actor Model 是一种替代多线程的并发解决方案。传统的并发解决方案是一种共享数据方式,使用共享数据的并发编程面临的最大问题是数据条件竞争,处理各种锁的问题是让人十分头疼的。
首先我们来看一下 Actor 的结构:

Actor 结构
Actor 模型的理念是:万物皆为 Actor
ActorActor 之间是通过消息通信,并不会直接共享每一个资源。
Actor 内部有一个 Mailbox ,可以理解为一个消息队列。所有消息都会先发送到 Mailbox
Actor 内部管理着自身的 State,这个 State 只有 Actor 自己可以访问。 让我们来看一下 Actor 和传统 OOP 的对比: image.png

示例

计数器
假设我们现在需要实现一个计数器,传统的实现应该是这样: image.png

如果不加锁,我们使用多线程对计数器进行累加操作后,是达不到我们想得到的目标值的。原因就是上面提到的 race condition。
经 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 来测试一下多线程下的计数器累加操作:

NSOperationQueue *queue = [[NSOperationQueue alloc] init];
    queue.maxConcurrentOperationCount = 15;
    for (int i = 0;i < 10000;i++) {
        NSBlockOperation *operation = [NSBlockOperation blockOperationWithBlock:^{
            [countActor sendMessage:@"inc"];
        }];
        [queue addOperation:operation];
    }
    [queue waitUntilAllOperationsAreFinished];
    co_launch(^{
        int currentCount = [await([countActor sendMessage:@"get"]) intValue];
        NSLog(@"count: %d", currentCount);
    });

可以看到最后输出的值是我们期望的目标值,可见 Actor 在多线程下是线程安全的。

求素数

假设现在有一个任务,利用多线程找100000以内的素数的个数,如果以传统的共享数据的方式,我们需要两个变量,一个记录当前找到了第几,一个记录当前素数的个数。因为是多线程,我们在操作这两个变量的时候需要对它们加锁。加锁的坏处大家也很清楚,首先是加锁的开销是很大的,而且如果不小心还会出现死锁。
下面我们来使用基于分布式的 Actor Model 来解决这个问题。
如果用 Actor 模型实现统计素数个数,那么我们需要1个 Actor 做原料的分发,就是提供要处理的整数,然后3个 Actor 加工,每次从分发 Actor 那里拿一个整数进行加工,最终把加工出来的半成品发给组装 Actor,组装 Actor 把3个加工 Actor 的结果汇总输出。
下面是一个分布式的结构:

分布式组装结构
COActor 就是 coobjc 为我们提供的 Actor 封装类,下面我们用 COActor 实现上面的分布式结构:
#import "Actor.h"
#import <coobjc/coobjc.h>

@interface Actor ()
@property (nonatomic, strong) COActor *assembler;
@property (nonatomic, strong) COActor *dispatcher;
@property (nonatomic, strong) NSMutableArray<COActor *> *processers;
@property (nonatomic, assign) NSTimeInterval startTime;
@end

@implementation Actor

- (instancetype)init
{
    self = [super init];
    if (self) {
        _processers = [NSMutableArray arrayWithCapacity:0];
        [self setupProcessers];
        __weak Actor *weakSelf = self;
        
        _assembler = co_actor_onqueue(dispatch_get_global_queue(0, 0), ^(COActorChan * _Nonnull chan) {
            __block int numCount = 0;
            for (COActorMessage *message in chan) {
                if ([message.stringType isEqualToString:@"add"]) {
                    numCount ++;
                } else if ([message.stringType isEqualToString:@"stop"]) {
                    NSTimeInterval costTime = CFAbsoluteTimeGetCurrent() - self.startTime;
                    NSLog(@"素数的个数是______%d 消耗的时间_________%f", numCount, costTime);
                }
            }
        });
        
        _dispatcher = co_actor_onqueue(dispatch_get_global_queue(0, 0), ^(COActorChan * _Nonnull chan) {
            for (COActorMessage *message in chan) {
                if ([message.stringType isEqualToString:@"start"]) {
                    weakSelf.startTime = CFAbsoluteTimeGetCurrent();
                    __block int number = 0;
                    while (number <= 100000) {
                        [weakSelf.processers enumerateObjectsUsingBlock:^(COActor *  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
                            dispatch_async(dispatch_get_global_queue(0, 0), ^{
                                [obj sendMessage:@(number++)];
                            });
                        }];
                    }
                }
            }
        });
    }
    return self;
}

- (void)setupProcessers {
    __weak Actor *weakSelf = self;
    for (int i = 0;i < 4;i++) {
        COActor *processer = co_actor_onqueue(dispatch_get_global_queue(0, 0), ^(COActorChan * _Nonnull chan) {
            for (COActorMessage *message in chan) {
                NSInteger currentNum = message.uintType;
                if ([weakSelf isPrimeNumber:currentNum]) {
                    [[weakSelf assembler] sendMessage:@"add"];
                }
                if (currentNum == 10000) {
                    NSLog(@"processer%d stop",i+1);
                    [weakSelf.assembler sendMessage:@"stop"];
                }
            }
        });
        [self.processers addObject:processer];
    }
}

- (BOOL)isPrimeNumber:(NSInteger)number {
    BOOL flag = YES;
    for (int i = 2;i < number;i++) {
        if (number % i == 0) {
            flag = NO;
            break;
        }
    }
    return flag;
}

- (void)startTask {
    [self.dispatcher sendMessage:@"start"];
}

@end

dispatcher 作为自然数的生产者,产生 10000 以内的自然数。
processer 作为加工者,来处理自然数是否是素数的工作。如果是素数,就会发消息到 assembler
assembler 作为组装者,有一个内部状态 numCount 记录目前素数的个数。当收到加工者的消息后,会把 numCount 自增。
可以看到,基于分布式的 Actor Model,很好的避免了传统并发模型下的共享资源,没有任何的锁操作,并且对每一个 Actor 都进行了明确的功能划分。

四.原理

刚才开篇有提到,协程与普通函数调用的区别就是,协程可以随时中断协程的执行,跳转到新的协程里面去,并且还能在需要的时候恢复。而普通的函数调用是没法作出暂停操作的。所以实现协程的关键就在于把当前调用栈上的状态都保存下来,然后再能从缓存中恢复。

基本上市面上实现这种操作的方法有以下五种:

这里我主要介绍第一种,其它的大家感兴趣可以自行 Google。
这里顺便说一下第一种方法在 iOS 上是已经被废弃的(可能是因为效率不高)。第三种和第四种只是能过做到跳转,但是没法保存调用栈上的状态,看起来基本上不能算是实现了协程,只能算做做demo,第五种除非官方支持,否则自行改写编译器通用性很差。所以阿里爸爸的库其实是使用第二种方法实现的。

uncontext

一.Hello world

uncontext 是一个 c 的协程库,底层也是通过汇编来实现的。首先我们来看一个最简单的例子:


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

运行这段代码,打印如下:

Hello world
Hello world
Hello world
Hello world
Hello world
Hello world
Hello world
...............

如果我们不终止程序,会一直打印 "Hello world"。
在这里我们初步可以看到,getcontext 保存了当前函数的执行上下文,当程序执行到 setcontext 的时候又会回到 getcontext 的那一行继续执行。

二.uncontext 深入

这里我们需要关注 <ucontext.h> 中定义的两个结构体类型 mcontext_tucontext_t,和四个函数 getcontext()setcontext()makecontext()swapcontext()
首先来看下 uncontext 的结构:

typedef struct ucontext {
               struct ucontext *uc_link;
               sigset_t         uc_sigmask;
               stack_t          uc_stack;
               mcontext_t       uc_mcontext;
               ...
           } ucontext_t;

下面说一下四个函数:

下面我们结合上面给的函数,来看一个简单的例子:


#include <ucontext.h>
#include <stdio.h>
 
void func1(void * arg)
{
    printf("func1");
}
void context_test()
{
    char stack[1024*128];
    ucontext_t child,main;
    print("context_test");
    getcontext(&child); //获取当前上下文
    child.uc_stack.ss_sp = stack;//指定栈空间
    child.uc_stack.ss_size = sizeof(stack);//指定栈空间大小
    child.uc_link = &main;//设置后继上下文
 
    makecontext(&child,(void (*)(void))func1,0);//修改上下文指向func1函数
 
    swapcontext(&main,&child);//切换到child上下文,保存当前上下文到main
    printf("main");//如果设置了后继上下文,func1函数指向完后会返回此处
}
 
int main()
{
    context_test();
 
    return 0;
}

执行上面代码,会打印出

contest_test
func1
main

现在来分析一下这段代码:按照函数执行顺序,首先会打印 "context_test" 。然后这里用 getcontext() 获取了当前上下文并设置了栈空间和后继上下文。注意这里 main 结构体并没有真正被赋值。随后调用了 makecontext() 并传入了一个指向 func1 的函数指针,那么在下面调用 swapcontext() 的时候就会调用这个函数指针所指向的函数,也就是 func1。swapcontext() 还有一个功能就是把当前上下文赋值给第一个参数,所以现在 main 里面存储了当前的上下文。最后当 func1 执行完之后,由于之前 child.uc_link 设置的是 main,所以会切换到 main 的上下文继续执行,所以我们看到了最后打印的 "main"。

现在我们稍微改动一下代码,把之前的 uc_link 赋值改为 child.uc_link = NULL,然后我们再次运行代码,可以看到打印变成了:

contest_test
func1

因为没有设置后继上下文,所以程序在执行完 func1 之后就直接结束了。

三.如何实现协程?

上面的例子只是简单的实现了从 主协程->协程1->主协程 这样一条路径,这样的路径普通函数调用也能实现。下面我们会介绍中断协程和恢复协程如何实现。
实现协程我们首先需要定义两个结构,一个是协程,一个是协程的调度器。
我这里的定义参考了 coobjc 和 uthread 的作者。

协程的结构体定义如下:

typedef void (*entryFunc)(void *arg);
 
typedef struct coroutine
{
    ucontext_t ctx; //当前协程的上下文,用于后继协程的存储
    entryFunc func; //协程需要执行的函数
    void *arg; // 函数的执行参数
    enum ThreadState state; //协程的运行状态,包括 FREE、RUNNABLE、RUNING、SUSPEND 四种。
    char stack[DEFAULT_STACK_SZIE]; //栈空间
    struct coroutine_scheduler *scheduler;  // 协程的调度器.
};

调度器的定义如下:

typedef std::vector<uthread_t> Thread_vector; //使用 c++数组来装载协程对象
 
typedef struct coroutine_scheduler
{
    ucontext_t main; // main的上下文
    int running_thread; // 当前正在运行的协程编号,如果没有返回 -1
    Thread_vector threads; //协程数组
 
    schedule_t():running_thread(-1){}
}

还需要实现4个比较关键的方法。
int uthread_create(schedule_t &schedule,Fun func,void *arg):创建协程

int uthread_create(schedule_t &schedule,Fun func,void *arg)
{
    int id = 0;
    
    for(id = 0; id < schedule.max_index; ++id ){
        if(schedule.threads[id].state == FREE){
            break;
        }
    }
    
    if (id == schedule.max_index) {
        schedule.max_index++;
    }
    // 加入到协程队列
    uthread_t *t = &(schedule.threads[id]);
    //初始化协程结构体
    t->state = RUNNABLE;
    t->func = func;
    t->arg = arg;
    //设置协程的上下文
    getcontext(&(t->ctx));
    
    t->ctx.uc_stack.ss_sp = t->stack;
    t->ctx.uc_stack.ss_size = DEFAULT_STACK_SZIE;
    t->ctx.uc_stack.ss_flags = 0;
    t->ctx.uc_link = &(schedule.main);
    schedule.running_thread = id;
    // 
    makecontext(&(t->ctx),(void (*)(void))(uthread_body),1,&schedule);
    swapcontext(&(schedule.main), &(t->ctx));
    
    return id;
}

void uthread_yield(schedule_t &schedule):挂起协程

void uthread_yield(schedule_t &schedule)
{
    if(schedule.running_thread != -1 ){
        uthread_t *t = &(schedule.threads[schedule.running_thread]);
        t->state = SUSPEND;
        schedule.running_thread = -1;

        swapcontext(&(t->ctx),&(schedule.main));
    }
}

void uthread_resume(schedule_t &schedule , int id):恢复协程

void uthread_resume(schedule_t &schedule , int id)
{
    if(id < 0 || id >= schedule.max_index){
        return;
    }

    uthread_t *t = &(schedule.threads[id]);

    if (t->state == SUSPEND) {
        swapcontext(&(schedule.main),&(t->ctx));
    }
}

int schedule_finished(const schedule_t &schedule):所有协程执行完毕

int schedule_finished(const schedule_t &schedule)
{
    if (schedule.running_thread != -1){
        return 0;
    }else{
        for(int i = 0; i < schedule.max_index; ++i){
            if(schedule.threads[i].state != FREE){
                return 0;
            }
        }
    }

    return 1;
}

接下来用 uthread 里提供的例子来验证一下协程库的运行:

void func1(void * arg)
{
    puts("1");
    puts("11");
}

void func2(void * arg)
{
    puts("22");
    puts("22");
    uthread_yield(*(schedule_t *)arg);
    puts("22");
    puts("22");
}

void func3(void *arg)
{
    puts("3333");
    puts("3333");
    uthread_yield(*(schedule_t *)arg);
    puts("3333");
    puts("3333");

}

void context_test()
{
    char stack[1024*128];
    ucontext_t uc1,ucmain;

    getcontext(&uc1);
    uc1.uc_stack.ss_sp = stack;
    uc1.uc_stack.ss_size = 1024*128;
    uc1.uc_stack.ss_flags = 0;
    uc1.uc_link = &ucmain;
        
    makecontext(&uc1,(void (*)(void))func1,0);

    swapcontext(&ucmain,&uc1);
    puts("main");
}

void schedule_test()
{
    schedule_t s;
    
    int id1 = uthread_create(s,func3,&s);
    int id2 = uthread_create(s,func2,&s);
    
    while(!schedule_finished(s)){
        uthread_resume(s,id2);
        uthread_resume(s,id1);
    }
    puts("main over");

}
int main()
{
// 执行 主协程->子协程->主协程
    context_test();
//主线程和子协程间任意切换
    schedule_test();

    return 0;
}

上面的 Demo 可以用下面的流程图来表示:

协程运行流程图
可以看到,在自己封装的协程库中,实现了主协程和子协程间的任意切换。并且切换之前都能保存当前的上下文,并不会从头开始执行。在这里其实就体现了文章一开始引用的维基百科中关于协程的介绍:「协程可以通过 yield 中断执行,转而执行别的协程。在这种转换过程中不存在调用者与被调用者的关系」,这是函数调用所不能实现的。

总结

协程本身没有特别适用的场景,但是当搭配上多线程之后,协程的光芒渐渐显露出来。我们可以这么总结一下:协程的线程可以让我们的程序并发的跑,协程可以让并发程序跑得看起来更美好
不管是异步代码同步化,还是并发代码简洁化,协程的出现其实是为代码从计算机向人类思维的贴近提供了可能。

参考资料

刚刚,阿里开源 iOS 协程开发框架 coobjc!
基于协程的编程方式在移动端研发的思考及最佳实践
iOS协程coobjc的设计篇-栈切换
coobjc

上一篇下一篇

猜你喜欢

热点阅读