Concurrency and isolates

김석윤·2025년 3월 19일

Flutter 공식문서

목록 보기
10/10
post-thumbnail

공식 문서

모든 dart 코드는 isolates에서 실행된다.
isolatesthreads와 비슷하지만, 독립적인 메모리를 가진다는 점이 다르다.
상태를 공유하지 않으며, messaging으로만 통신한다.

기본적으로 플러터는 single isolate로 모든 작업을 처리한다. (main isloate)
대부분의 경우, 이 모델은 단순한 프로그래밍을 가능하게 하며, 앱의 UI가 멈추지 않을 정도로 빠르다. (UI jank X)

가끔 앱이 큰 연산을 수행할 때가 있는데 이게 UI jank(jerky motion)의 원인이 된다.
앱이 이러한 이유로 jank된다면, 해당 연산 처리를 helper isolate로 옮기면 된다.
런타임 환경에서 main ui isolate동시에 연산 처리를 진행할 수 있고, 멀티코어 장치의 이점도 활용할 수 있다.

각각의 isolate는 본인 만의 메모리와 이벤트 루프를 가진다.
이벤트 루프는 이벤트 큐에 진입한 순서대로 이벤트를 처리한다.
main isolate에서 이벤트는 어떤 것이든 될 수 있다.
유저가 UI를 tapping하는 것부터 함수 실행, 화면에 프레임을 그리는 것까지 가능하다.
아래 그림은 3개의 이벤트가 이벤트 큐에서 처리를 기다리는 그림이다.

부드럽게 렌더링하기 위해서 플러터는 paint frame 이벤트를 이벤트 큐에 1초에 60번(60Hz 디바이스 기준) 넣는다.
만약 이 이벤트들이 제시간에 동작하지 않는다면, 앱은 UI jank를 겪거나 심할 경우 반응하지 않는다.

프로세스가 frame gap(2 frames) 내에 끝나지 않는다면, 다른 isolate에게 일을 할당하여 main isolate1초에 60프레임을 유지하도록 만드는 것이 좋다.
Dart에서 isolate를 spawn하면 blocking 없이 main isolate와 함께 동시에 작업을 진행할 수 있다.

Common use cases for isolates

isolates를 반드시 사용해야 할 한가지 경우가 있다.
큰 연산 처리로 인해 앱이 UI jank를 겪을 때이다.
이 jank는 플러터의 frame gap보다 연산 처리가 길 때 발생한다.

어떤 작업이든 구현 방식과 입력 데이터에 따라 완료까지 걸리는 시간이 달라질 수 있다.
따라서, 언제 isolate를 사용해야 하는지 철저하게 리스트를 만드는 것은 불가능하다.
다만, 아래의 상황에서 보통 많이 사용한다.

  • 로컬 DB에서 데이터를 읽을 때
  • 푸시 알림을 보낼 때
  • 큰 데이터 파일을 파싱하고, 해독할 때
  • 사진, 오디오 파일, 비디오 파일을 압축 또는 재생할 때
  • 오디오와 비디오 파일을 변환할 때
  • FFI(Foreign Function Interface)를 사용하면서 비동기 지원이 필요할 때
  • 복잡한 리스트나 파일 시스템을 필터링해서 적용할 때

Message passing between isolates

Dart의 isolate는 Actor Model의 구현체다.
(Actor Model에서 각 Actor는 독립적으로 동작하며, 상태를 공유하지 않고 메시지로 통신한다.)
isolates는 Port 객체를 통해 메시지 패싱 방식으로 통신한다.
메시지가 전달되면, 송신 isolate를 수신 isolate에서 복사한다.
즉, isolate에서 전달된 값이 변경 되더라도, 원래의 isolate 값에 영향을 미치지 않는다.

isolate에 전달될 때 복사되지 않는 유일한 객체는 불변 객체다.
예를 들어, String 또는 수정할 수 없는 바이트 데이터가 이에 해당된다.
불변 객체를 isolate에 전달하면 객체가 복사되는 것이 아니라 해당 객체의 참조값이 port를 통해 전달된다.
이렇게 하면 성능이 향상된다.
그리고 불변 객체는 변경될 수 없기 때문에 Actor Model의 동작 방식(독립 상태 유지)이 그대로 유지된다.

이 규칙의 예외는 Isolate.exit 메서드로 isolate가 종료될 때 메시지를 보내는 경우다.
송신 isolate가 message를 보낸 이후에 존재하지 않기 때문에, 메시지의 소유권을 다른 isolate에게 넘길 수 있다.
이렇게 하면 하나의 isolate만 메시지에 접근하도록 보장한다.

  • 일반 메시지 전달(sendPort.send)
    • 송신 isolate: 원본 데이터 유지
    • 수신 isolate: 복사본 받음
    • 메시지 전달 방식: 데이터를 복사하여 전달
  • Isolate.exit
    • 송신 isolate: 종료(데이터 접근 불가)
    • 수신 isolate: 원본 데이터 소유
    • 메시지 전달 방식: 데이터 소유권을 이전하여 전달

메시지를 보내는 가장 원시적인 방법은 2가지가 있다.
1. Sendport.send: 가변 메시지를 복사해 보낸다.
2. Isolate.exit: 메시지 참조값을 보낸다.

Isolate.runcompute도 내부적으로 Isolate.exit을 사용한다.

Short-lived isolates

플러터에서 가장 쉽게 프로세스를 isolate로 옮기는 방법은 Isolate.run 메서드를 사용하는 거다.

이 메서드는 아래의 과정을 거친다.
1. isolate를 spawn(생성)
2. spawned isolate에서 실행할 콜백을 전달하여 연산 시작
3. 연산의 결과값을 반환
4. 연산이 끝나면 종료

이 과정은 main isolate와 동시에 진행되고, main isolate를 block하지 않는다.

Isolate.run 메서드는 하나의 인수를 필요로 하는데, 이는 새로운 isolate에서 실행될 콜백 함수이다.
콜백 함수는 required unnamed 인수를 가진다.
연산이 완료되면, main isolate에게 연산 결과를 반환하고, spawned isolate를 종료한다.

예를 들어, 큰 JSON blob 형태의 데이터를 파일에서 로드하고, 커스텀 객체로 변환한다고 하자.
만약 json 디코딩 과정이 새로운 isolate에서 실행되지 않는다면 몇 초 동안 UI가 반응하지 않을 것이다.

// Produces a list of 211,640 photo objects.
// (The JSON file is ~20MB.)
Future<List<Photo>> getPhotos() async {
  final String jsonString = await rootBundle.loadString('assets/photos.json');
  final List<Photo> photos = await Isolate.run<List<Photo>>(() {
    final List<Object?> photoData = jsonDecode(jsonString) as List<Object?>;
    return photoData.cast<Map<String, Object?>>().map(Photo.fromJson).toList();
  });
  return photos;
}

Stateful, longer-lived isolates

Short-live isolates는 편하지만 새로운 isolates를 spawn(생성)하면 성능 오버헤드가 발생하며, 객체를 복사하는 비용도 생긴다.
같은 연산 처리를 Isolate.run으로 반복한다면, isolates를 즉각 종료시키지 않는 것이 성능상 유리할 수 있다.

이를 수행하려면 Isolates.run이 추상화한 몇 가지 isolate 관련 api를 사용하자.

  • Isolates.spawn & Isolates.exit
  • ReceivePort & SendPort
  • send 메서드

✅ 즉, 하나의 Isolate를 유지하면서 SendPort로 데이터를 주고받는 방식이 더 효율적이다.

Isolates.run을 사용하면, 새로운 isolates가 main isolate에게 메시지를 반환하는 순간 종료된다.
하지만 여러 메시지를 지속적으로 주고 받아야 할 경우, isolates는 장시간 유지되어야 한다.
Dart에서 이를 Isolate APIPort로 구현할 수 있다.
이렇게 장시간 유지되는 isolates를 background workers라고 한다.

Long-lived isolates는 아래의 경우에서 사용하면 유용하다.

  • 앱이 실행되는 동안 반복적으로 수행해야 하는 프로세스가 있는 경우
    ex. 백그라운드 데이터 동기화, 주기적인 네트워크 요청, 스트리밍 데이터 처리 등

  • 오랜 시간 수행되면서 여러 개의 반환값을 main isolate에 전달하는 경우
    ex. 대용량 파일 처리, 실시간 센서 데이터 수집, AI 모델 추론 결과 전송 등

✅ 즉, 단순히 한 번 실행하고 끝나는 작업이 아니라, 지속적으로 데이터를 주고받으며 실행되는 백그라운드 작업에 적합합니다.

아니면 worker_manager로 long-lived isolates를 관리해도 된다.

ReceivePorts and SendPorts

isolates 사이에서 장시간 통신하려면 ReceivePortSendPort를 사용해야 한다.
해당 port들이 isolates끼리 통신할 수 있는 유일한 방법이다.

Ports는 Streams와 유사하게 동작한다.
하나의 isolate에서 StreamController 또는 Sink를 만들고, 다른 isolate가 리스너를 설정한다.
이와 같은 개념으로, StreamController는 SendPort라 불리고, send 메서드로 메시지를 보낼 수 있다.
ReceivePort는 리스너고, 새로운 메시지를 받으면 지정된 콜백을 호출하고, 메시지를 인자로 보낸다.

Using platform plugins in isolate

플러터 3.7부터 백그라운드 isolates에서 플랫폼 플러그인을 사용할 수 있다.
이를 통해 무거운 작업이나 플랫폼 의존적인 연산을 isolate에서 할 수 있어서 UI가 block되지 않는다.
예를 들어, android에서 android api를, ios에서 ios api를 사용해 데이터를 암호화한다고 하자.
이전에는 marshaling data를 호스트 플랫폼으로 전달하는 과정에서 UI thread 시간을 소모했다.

  • marshaling data
    • 데이터를 한 형태에서 다른 형태로 변환하는 과정
    • 데이터를 직렬화(serialize)하여 전송하고, 이를 복원(deserialize)하는 과정

하지만 이제는 이를 백그라운드 isolate에서 처리할 수 있다.

플랫폼 채널 isolates는 BackgroundIsolateBinaryMessenger API를 사용한다.
아래의 예제는 shared_preference를 백그라운드 isolate에서 사용한 예시다.

import 'dart:isolate';

import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';

void main() {
  // Identify the root isolate to pass to the background isolate.
  RootIsolateToken rootIsolateToken = RootIsolateToken.instance!;
  Isolate.spawn(_isolateMain, rootIsolateToken);
}

Future<void> _isolateMain(RootIsolateToken rootIsolateToken) async {
  // Register the background isolate with the root isolate.
  BackgroundIsolateBinaryMessenger.ensureInitialized(rootIsolateToken);

  // You can now use the shared_preferences plugin.
  SharedPreferences sharedPreferences = await SharedPreferences.getInstance();

  print(sharedPreferences.getBool('isDebug'));
}

Limitations of Isolates

멀티스레딩을 지원하는 언어를 사용해 봤다면 isolates가 threads처럼 동작하는 거라 생각하겠지만 그렇지 않다.
Isolates는 각각 독립적인 전역 필드를 가지고, 메시지 패싱으로만 통신한다.
또한, isolate 내에 있는 가변 객체는 해당 isolate에서만 접근할 수 있도록 보장한다.
따라서, isolates는 본인의 메모리에만 접근할 수 있다는 제한이 있다.

예를 들어, 앱에 configuration이라는 전역 가변 변수가 있다고 하자.
이를 spawned isolate에서 사용하면, 해당 변수는 새로운 전역 필드에 복사된다.
spawned isolate에 있는 변수를 변경해도 main isolate에 있는 건 변경되지 않는다.
configuration 객체를 메시지로 전달해도 마찬가지다.

이것이 isolates의 기본 동작 방식이며, isolates를 사용할 때 항상 염두해둬야 한다.

Web platforms and compute

Dart 웹 플랫폼은 isolate를 지원하지 않는다.
만약 플러터앱으로 웹을 만든다면 compute 메서드를 사용해야 코드가 컴파일된다.
웹에서는 compute 메서드가 main thread에서 실행되지만, 모바일 기기에서는 새로운 스레드를 생성하여 실행한다.
모바일과 데스크탑에서 await compute(fun, message)await Isolate.run(() => fun(message))와 동일하다.

No rootBundle access or dart:ui methods

모든 UI 작업과 플러터 자체는 main isolate와 결합되어 있다.
그러므로 spawned isolates에서 rootBundle을 사용해도 assets에 접근할 수 없다.
또한, 어떠한 위젯이나 UI 작업도 수행할 수 없다.

Limited plugin messages from host platform to Flutter

백그라운드 isolate에서 platform channel을 사용하면, android나 ios와 같은 호스트 플랫폼에 메시지를 보낼 수 있다.
그리고 메시지에 대한 응답값도 받을 수 있다.
하지만 호스트 플랫폼으로부터 요청하지 않은 메시지는 받을 수 없다.

예를 들어, 백그라운드 isolate에서 장시간 Firestore 리스너를 설정할 수 없다.
이는 Firestoreplatform channel을 사용해 플러터로 업데이트를 푸시하는데, 이러한 메시지는 요청되지 않은(unsolicited) 메시지이기 때문이다.

그러나 백그라운드에서 Firestore에 쿼리를 보내고 응답을 받을 수는 있다.

profile
Software Engineer

0개의 댓글