Dart中的Isolate

Dart中的Isolate

Dart语言,是Google在2011年推出的Web开发语言,由Chrome浏览器V8引擎团队的Lars Bak主持,目的是要取代JavaScript称为下一代结构化Web开发语言。
不过由于NodeJs在兴起,让JavaScript拥有了服务端开发能力,慢慢使Dart淡出了开发者的视野。随着Flutter跨平台开发框架的流行,其使用的开发语言Dart也再次焕发活力。那么Dart语言到底有什么特点或优势呢?

  • 面向对象,基于类编程
  • 单线程模型,强大的事件驱动机制处理异步任务,通过Isolate实现并发
  • 简单的内存分配和高效的垃圾回收机制
  • 同时支持JIT和AOT
  • 声明式UI布局

既然Dart在诞生之初是为了对标JavaScript,并且开发团队也是JavaScript的解释引擎V8团队,所以两者有很多相似共通之处。比如面向对象、单线程模型和事件循环,解释执行。
除了这些,Dart在设计之初就针对JavaScript的短板进行弥补。比如JavaScript低效的解释执行,而Dart可以在运行前直接编译为机器码,提高了执行效率,这个过程叫做AOT(Ahead of time)。还有JavaScript的单线程模型,只能依赖JS解释引擎的异步任务执行机制,开发者没有办法自己启动新的线程去执行耗时代码,但是Dart却提供了这个能力 — Isolate。通过查资料发现,Isolate是一种特殊的线程,多个Isolate之间内存独立不共享,而我们概念中的线程是共享其所在进程的内存的。

今天我们就深入讨论一下Dart中的Isolate是如何实现的。在开始之前我们先给自己预设几个问题,带着问题去阅读代码会更有目的性:

  1. Isolate是什么意思?一个东西的名字很大程度上反应了它的特点。
  2. 如何编写Isolate代码?
  3. Isolate的底层实现到底是线程还是进程?
  4. Isolate是如何实现内存隔离的?
  5. 如果是内存隔离的,那多个Isolate之间是如何通信的?
  6. 为什么将Isolate设计成内存隔离的?

Isolate是什么意思

Isolate直译过来是“隔离”的意思,也就是各个Isolate是不干扰的,互相隔离的。

Dart中给它的定义是:

An isolate is the unit of concurrency in Dart. Each isolate
has its own memory and thread of control. No state is 
shared between isolates. Instead, isolates communicate by 
message passing.

Each thread keeps track of its current isolate, which is 
the isolate which is ready to execute on the current 
thread. The current isolate may be NULL, in which case no 
isolate is ready to execute. Most of the Dart apis require 
there to be a current isolate in order to function without 
error. The current isolate is set by any call to 
Dart_CreateIsolateGroup or Dart_EnterIsolate.

编写Isolate代码

前面说到Isolate是内存独立的,那么我们需要编写一个Isolate程序验证一下。

import 'dart:async';
import 'dart:isolate';

int i = 0;
IntObject intObject = IntObject();

void main() async {
  //Isolate的消息接收端口
  final receive = ReceivePort();
  //为消息接收端口添加监听
  receive.listen((data) {
    print(
        "Main: Receive data =  $data, i = $i, intObject = ${intObject.get()}");
  });

  // 创建一个Isolate实例,1.指定入口函数 2.指定消息通信发送端口
  Isolate isolate = await Isolate.spawn(isolateEntryFunction, receive.sendPort);

  print(DateTime.now().toString() + " start...");
}

// isolate入口函数,该函数必须是静态的或顶级函数,不能是匿名内部函数。
void isolateEntryFunction(SendPort sendPort) {
  int counter = 0;

  Timer.periodic(const Duration(seconds: 3), (_) {
    counter++;
    //在单独的Isolate实例中修改i的值
    i++;
    intObject.increase();

    String sendMsg = "Notification data: $counter";
    print(DateTime.now().toString() +
        " Isolate: counter = $counter, i = $i, intObject = ${intObject.get()}");
    sendPort.send(sendMsg);
  });
}

class IntObject {
  int _i = 0;

  void increase() {
    _i++;
  }

  int get() {
    return _i;
  }
}

上述代码输出为:

2019-10-31 11:54:13.525943 start...
2019-10-31 11:54:16.537207 Isolate: counter = 1, i = 1, intObject = 1
Main: Receive data =  Notification data: 1, i = 0, intObject = 0
2019-10-31 11:54:19.531389 Isolate: counter = 2, i = 2, intObject = 2
Main: Receive data =  Notification data: 2, i = 0, intObject = 0
2019-10-31 11:54:22.527753 Isolate: counter = 3, i = 3, intObject = 3
Main: Receive data =  Notification data: 3, i = 0, intObject = 0
2019-10-31 11:54:25.532155 Isolate: counter = 4, i = 4, intObject = 4
Main: Receive data =  Notification data: 4, i = 0, intObject = 0

上面代码的含义是:
在main()函数中新建了一个Isolate实例,并指定了入口函数void isolateEntryFunction(SendPort sendPort)和接收端的发送端口。isolateEntryFunction函数将在新建的Isolate中执行。

在顶部声明了一个整型i和一个对象变量intObject,在新建中的Isolate改变两者的值并输出到控制台,然后通过消息端口发送消息给main()函数所在的Isolate,同时也将两个变量的值输出到控制台。

结论就是:
在新建的Isolate中修改i和intObject变量的值后,在main()函数所在Isolate中不能获取到,所以Isolate在内存上是隔离的。
同时,Isolate也提供了通信机制,叫做端口。

Isolate的底层实现到底是线程还是进程

虽然在内存表现上,Isolate内存隔离性像是进程的特点。但是从实现上不可能把Isolate作为一个进程,因为进程太重了,每新建一个进程,内核系统都会为新进程创建独立的虚拟内存,保存进程相关的数据结构,并且进程切换效率比较低。所以从可行性上来说Isolate的本质应该是一个线程。

那么下面从源码层面上验证Isolate是否是一个线程。

Dart中的Isolate_第1张图片

上面的调用时序图是从下面代码开始的。

Isolate isolate = await Isolate.spawn(isolateEntryFunction, receive.sendPort);

在第4、5步可以看出在创建Isolate过程中用到了线程和线程池,新Isolate的代码会在单独线程中执行。下面看一下SpawnIsolateTask的源码来证实这一点。

class SpawnIsolateTask : public ThreadPool::Task {
    
    void Run() override {
        ...
        
        if (isolate->is_runnable()) {
          isolate->Run();
        }    
    }
}

创建完SpawnIsolateTask后,便会执行下面代码来运行SpawnIsolateTask任务。

Dart::thread_pool()->Run(isolate, std::move(state));

执行ThreadPool的Run方法后,会将SpawnIsolateTask封装成一个Worker,并执行Worker的StartThread方法。然后会创建一个新线程并回调执行Worker的静态方法Main。在Main方法中会开启一个任务循环,不停执行给这个Worker设置的task。当然,第一次执行的task就是SpawnIsolateTask。

线程创建过程如下:

void ThreadPool::Worker::StartThread() {
    ...
    int result = OSThread::Start("Dart ThreadPool Worker", &Worker::Main,
                               reinterpret_cast(this));
                               
    ...
}

这里的线程创建要区分不同的底层系统,像类Unix系统(Android、Linux、MaxOS)的线程创建使用pthread_create系统调用。而Windows系统的线程创建则使用_beginthreadex函数。
下面展示Android系统上的线程创建过程:

// vm/os_thread_android.cc

int OSThread::Start(const char* name,
                    ThreadStartFunction function,
                    uword parameter) {
  pthread_attr_t attr;
  int result = pthread_attr_init(&attr);
  RETURN_ON_PTHREAD_FAILURE(result);

  result = pthread_attr_setstacksize(&attr, OSThread::GetMaxStackSize());
  RETURN_ON_PTHREAD_FAILURE(result);

  ThreadStartData* data = new ThreadStartData(name, function, parameter);

  pthread_t tid;
  //系统调用,创建新线程,并指定了入口函数
  //这里有一个小技巧,就是并没有直接把入口函数function传递给pthread_create。
  //而是将function封装到ThreadStartData结构体中,将ThreadStart函数作为入口函数。
  //然后在ThreadStart函数中取出ThreadStartData中真正的入口函数function并执行。
  result = pthread_create(&tid, &attr, ThreadStart, data);
  RETURN_ON_PTHREAD_FAILURE(result);

  result = pthread_attr_destroy(&attr);
  RETURN_ON_PTHREAD_FAILURE(result);

  return 0;
}

// 使用ThreadStart函数作为线程入口函数跳板的目的是需要在线程创建完成后做些其他的事情。
// 1. 创建一个OSThread对象,与当前线程对应
// 2. 将OSThread保存到链表头部
// 3. 将OSThread对象保存到ThreadLocal中 
// 保存这些信息的用途就是在需要结束线程时,可以被正确的销毁
static void* ThreadStart(void* data_ptr) {
  ThreadStartData* data = reinterpret_cast(data_ptr);

  const char* name = data->name();
  OSThread::ThreadStartFunction function = data->function();
  uword parameter = data->parameter();
  delete data;

  // Set the thread name.
  pthread_setname_np(pthread_self(), name);

  // Create new OSThread object and set as TLS for new thread.
  OSThread* thread = OSThread::CreateOSThread();
  if (thread != NULL) {
    OSThread::SetCurrent(thread);
    thread->set_name(name);
    UnblockSIGPROF();
    // Call the supplied thread start function handing it its parameters.
    function(parameter);
  }

  return NULL;
}

Isolate是如何实现内存隔离的

理解了Isolate的本质是一个线程后,就该探究一下不同的Isolate(线程)之间是如何做到的内存隔离的。这里的内存就是指堆内存。
下面看一张DalvikVM与Dart VM内存对比图:

Dart中的Isolate_第2张图片

从图中可以看出,每个Isolate都有独自的运行时栈结构和堆内存,而Dalvik虚拟机中的多个线程共享一块堆内存。

那怎么从代码层面证实呢?我们可以猜测每个Isolate的堆内存肯定是随着Isoalte创建而初始化的,那么在Isolate的创建流程中可以找到一些蛛丝马迹。

Dart中的Isolate_第3张图片

通过跟踪Isolate的创建过程中,可以看到在新建一个Isolate实例后,会初始化一个Heap对象,并设置给Isolate。这个Heap应该就是当前Isolate独享的堆内存管理器。

Heap的初始化位于vm/isolate.cc文件的InitIsolate函数中。

// vm/isolate.cc

Isolate* Isolate::InitIsolate(const char* name_prefix,
                              IsolateGroup* isolate_group,
                              const Dart_IsolateFlags& api_flags,
                              bool is_vm_isolate) {
  Isolate* result = new Isolate(isolate_group, api_flags);
  ...
  
    Heap::Init(result,
             is_vm_isolate
                 ? 0  // New gen size 0; VM isolate should only allocate in old.
                 : FLAG_new_gen_semi_max_size * MBInWords,
             (is_service_or_kernel_isolate ? kDefaultMaxOldGenHeapSize
                                           : FLAG_old_gen_heap_size) *
                 MBInWords);
                 
    ... 
     
}

从代码中可以看Isolate的堆内存也被区分为新生代和老年代两块区域,Dart虚拟机针对不同的区域执行不同的垃圾回收策略:
新生代采用复制清除算法,针对频繁创建销毁的页面控件对象,可以从内存层面实现快速分配和回收。
老年代采用标记清除标记整理两种算法,来适应不同的内存回收场景,尽量保证UI的流畅性。
这里也就解释了在文章开头提到的Dart中简单内存分配模型,和高效垃圾的回收机制

那么两个内存区域大小的分配策略是什么样的呢?

新生代大小 = 0(虚拟机Isolate)或者 new_gen_semi_max_size 兆

  if (kWordSize <= 4) {
    vm_options.AddArgument("--new_gen_semi_max_size=16");
  } else {
    vm_options.AddArgument("--new_gen_semi_max_size=32");
  }

new_gen_semi_max_size变量在程序启动时(runtime/bin/main.cc)被指定,即32位设备上为16M, 超过32位则是32M。

老年代大小 = kDefaultMaxOldGenHeapSize 兆

const intptr_t kDefaultMaxOldGenHeapSize = (kWordSize <= 4) ? 1536 : 15360;

P(old_gen_heap_size, int, kDefaultMaxOldGenHeapSize,                         
    "Max size of old gen heap size in MB, or 0 for unlimited,"                 
    "e.g: --old_gen_heap_size=1024 allows up to 1024MB old gen heap")   

默认情况下kDefaultMaxOldGenHeapSize 与 old_gen_heap_size相等,即32位上1536M,64位上15360M,除非在程序启动时指定old_gen_heap_size的值。

Isolate之间的通信机制

如果是内存隔离的,那多个Isolate之间是如何通信的?

通过前面的讲解,我们知道在创建Isolate时需要指定一个接收端口(ReceivePort)的发送端口(SendPort),调用者可以通过这个发送端口发送数据到其他的Isolate中ReceivePort的listen中,这种机制被称为消息传递(message passing)。

目前DartVm支持的消息数据类型为:

  • 原始数据类型,如 null,num,bool,double,String等
  • SendPort实例
  • 包含前面两种类型的list和map,也可以嵌套使用
  • 在DartVM中,处于同一进程的两个Isolate,也可以发送实例对象。但是dart2js编译器是不可以的。

那么在代码实现层面是如何做到的呢?

先看下面两段代码:

//vm/isolate.cc

Isolate* Isolate::InitIsolate(const char* name_prefix,
                              IsolateGroup* isolate_group,
                              const Dart_IsolateFlags& api_flags,
                              bool is_vm_isolate) {
                              
    Isolate* result = new Isolate(isolate_group, api_flags);
    // Setup the isolate message handler.
    MessageHandler* handler = new IsolateMessageHandler(result);
    result->set_message_handler(handler);
}


void Isolate::Run() {
  message_handler()->Run(Dart::thread_pool(), RunIsolate, ShutdownIsolate,
                         reinterpret_cast(this));
}

在创建Isolate实例时,为Isolate设置了一个IsolateMessageHandler实例,这个IsolateMessageHandler就是跨Isolate的消息处理器,并在启动Isolate时,执行IsolateMessageHandler实例的Run方法,其内部会启动一个循环来处理消息。

发送端的一个消息会经过IsolateMessageHandler转发给接收端事先注册的处理函数。

// vm/dart_entry.cc


RawObject* DartLibraryCalls::HandleMessage(const Object& handler,
                                           const Instance& message) {

    // 1、从当前线程取出Isolate实例
    Thread* thread = Thread::Current();
    Zone* zone = thread->zone();
    Isolate* isolate = thread->isolate();
    // 2、从Isolate实例中取出处理消息的函数
    Function& function = Function::Handle(
      zone, isolate->object_store()->handle_message_function());
    
    // 3、封装参数  
    const Array& args = Array::Handle(zone, Array::New(kNumArguments));
    args.SetAt(0, handler);
    args.SetAt(1, message);
    
    // 4、执行函数调用
    const Object& result =
      Object::Handle(zone, DartEntry::InvokeFunction(function, args));
      
    return result.raw()
}

上面是处理消息的流程,当调用者通过sendPort发送一个消息时,DartVM会组装一个消息放到接受者的消息队列中由接收者处理。代码调用流程如下:

lib/isolate_patch.dart/_SendPortImpl 
-> lib/isolate.cc/SendPortImpl_sendInternal_
-> vm/port.cc/PortMap::PostMessage (FindPort, find port MessageHandler,handler->PostMessage)
-> vm/message_handler.cc/PostMessage (oob_queue_.Enqueue / queue_->Enqueue )

其中关键代码在于:

// vm/port.cc

bool PortMap::PostMessage(std::unique_ptr message,
                          bool before_events) {
  //根据消息中的目的端口找到对应的MessageHandler
  intptr_t index = FindPort(message->dest_port());
  if (index < 0) {
    return false;
  }
  
  // map_变量是一个Hash表,存储Port和MessageHandler的对应关系
  MessageHandler* handler = map_[index].handler;
  //将消息交由MessageHandler处理
  handler->PostMessage(std::move(message), before_events);
  return true;
}

既然是内存隔离的,那么在调用者所在Isolate发送的消息数据是怎么传递到接收者所在的Isolate中的呢?
答案也是这个Entry* 类型的变量map_,map_变量是在Dart虚拟启动时初始化的,所以map_变量是存在于Dart虚拟机所属内存的,而这块内是各个Isolate共享的。初始化代码如下:

// vm/dart.cc

char* Dart::Init(const uint8_t* vm_isolate_snapshot,
                 const uint8_t* instructions_snapshot,
                 Dart_IsolateGroupCreateCallback create_group,
                 Dart_InitializeIsolateCallback initialize_isolate,
                 Dart_IsolateShutdownCallback shutdown,
                 Dart_IsolateCleanupCallback cleanup,
                 Dart_IsolateGroupCleanupCallback cleanup_group,
                 Dart_ThreadExitCallback thread_exit,
                 Dart_FileOpenCallback file_open,
                 Dart_FileReadCallback file_read,
                 Dart_FileWriteCallback file_write,
                 Dart_FileCloseCallback file_close,
                 Dart_EntropySource entropy_source,
                 Dart_GetVMServiceAssetsArchive get_service_assets,
                 bool start_kernel_isolate,
                 Dart_CodeObserver* observer) {
                 
    ...
    
    VirtualMemory::Init();
    OSThread::Init();
    
    Isolate::InitVM();
    
    //初始化Port与MessageHandler的哈希表
    PortMap::Init();
    FreeListElement::Init();
    ForwardingCorpse::Init();
    Api::Init();
    NativeSymbolResolver::Init();
    NOT_IN_PRODUCT(Profiler::Init());
    SemiSpace::Init();
    NOT_IN_PRODUCT(Metric::Init());
    StoreBuffer::Init();
    MarkingStack::Init();
    
    thread_pool_ = new ThreadPool();
    ...
    
    Api::InitHandles();
    
    const bool is_dart2_aot_precompiler =
    FLAG_precompiled_mode && !kDartPrecompiledRuntime;

    if (!is_dart2_aot_precompiler &&
      (FLAG_support_service || !kDartPrecompiledRuntime)) {
    ServiceIsolate::Run();
    }
    
    #ifndef DART_PRECOMPILED_RUNTIME
    if (start_kernel_isolate) {
        KernelIsolate::Run();
    }
    #endif
    
    return NULL;
}

可以看到在虚拟机启动时初始化了很多东西,例如线程池、虚拟内存、系统线程等。
下面通过一张图更能清楚的看出Isolate与虚拟机内存之间的关系。

Dart中的Isolate_第4张图片

为什么将Isolate设计成内存隔离的?

下面是我对这个问题的一些思考:
首先说目前由移动端页面(包含Android、iOS、Web)构建的特性—树形结构构建布局、布局解析抽象、绘制、渲染,这一系列的复杂步骤导致必须在同一个线程完成(除了单独的渲染想线程),因为多线程操作页面UI元素会有并发的问题,有并发就必须要加锁,加锁就会降低执行效率。所以强制在同一线程中操作UI是最好的选择。

除此之外,每当有页面交互时,必定会引起布局变化而重新绘制,这个过程会有频繁的大量的UI控件的创建和销毁,这就涉及到了耗时内存分配和回收。当然,为了降低UI重绘的耗时,出现了很多辅助技术,比如控件缓存重用、局部重绘、布局边界等,以及检测布局深度和过度绘制的工具。而这些较短生命周期的对象是存放在堆内存的新生代的,当虚拟机回收新生代内存时是要stop the world的,在Android或iOS中,各个线程共用一块堆内存,当非UI线程频繁申请、释放内存时也会触发垃圾回收,所以会间接影响UI线程的运行。

Dart为了解决这个问题,就每个Isolate(看做线程)分配各自的一块堆内存,并且独自管理内。这样的策略使得内存的分配和回收变得简单高效,并且不受其他Isolate的影响。

你可能感兴趣的:(Flutter)