Dart의 동시성

Dart의 동시성 (Concurrency in Dart)

isolate를 사용해 여러 프로세서 코어에서 병렬로 코드를 실행하는 방법을 살펴볼게요. Dart의 동시성은 비동기 API와 isolate라는 두 축으로 이뤄져 있는데, 그 개념을 하나씩 풀어봐요.

출처: Dart 공식 문서

본문

이 페이지는 Dart에서 동시성 프로그래밍이 어떻게 동작하는지에 대한 개념적 개요를 담고 있어요. 이벤트 루프, async 언어 기능, isolate를 높은 수준에서 설명해요. Dart에서 동시성을 다루는 더 실용적인 코드 예시는 비동기 프로그래밍(Asynchronous programming) 페이지와 Isolates 페이지에서 볼 수 있어요.

Dart의 동시성 프로그래밍은 FutureStream 같은 비동기 API와, 프로세스를 별도의 코어로 옮길 수 있게 해주는 isolates를 모두 가리켜요.

모든 Dart 코드는 isolate 안에서 실행돼요. 기본 main isolate에서 시작하고, 선택적으로 이후에 명시적으로 만드는 isolate들로 확장돼요. 새 isolate를 스폰(spawn)하면 그 isolate만의 격리된 메모리와 자체 이벤트 루프를 가져요. 이벤트 루프야말로 Dart에서 비동기·동시성 프로그래밍을 가능하게 하는 핵심이에요.

이벤트 루프 (Event Loop)

Dart의 런타임 모델은 이벤트 루프를 기반으로 해요. 이벤트 루프는 프로그램 코드를 실행하고, 이벤트를 수집·처리하는 일 등을 담당해요.

애플리케이션이 실행되는 동안 모든 이벤트는 이벤트 큐(event queue) 라는 대기열에 추가돼요. 이벤트는 UI를 다시 그리라는 요청부터 사용자의 탭과 키 입력, 디스크에서의 I/O까지 무엇이든 될 수 있어요. 앱은 이벤트가 어떤 순서로 일어날지 예측할 수 없으므로, 이벤트 루프는 큐에 들어온 순서대로 이벤트를 하나씩 처리해요.

이벤트가 하나씩 이벤트 루프로 들어가는 그림

이벤트 루프가 동작하는 방식은 다음 코드와 비슷해요.

while (eventQueue.waitForEvent()) {
  eventQueue.processNextEvent();
}

이 예시 이벤트 루프는 동기적이고 단일 스레드에서 실행돼요. 하지만 대부분의 Dart 애플리케이션은 한 번에 둘 이상의 일을 해야 해요. 예를 들어 클라이언트 애플리케이션은 HTTP 요청을 실행하면서 동시에 사용자가 버튼을 탭하는지 듣고 있어야 할 수 있어요. 이를 처리하기 위해 Dart는 Futures, Streams, async-await 같은 많은 async API를 제공해요. 이 API들은 바로 이 이벤트 루프를 중심으로 구축돼 있어요.

예를 들어 네트워크 요청을 보내는 경우를 볼게요.

http.get('https://example.com').then((response) {
  if (response.statusCode == 200) {
    print('Success!');
  }
}

이 코드가 이벤트 루프에 도달하면, 즉시 첫 번째 절인 http.get을 호출하고 Future를 반환해요. 그리고 이벤트 루프에게 HTTP 요청이 해결될 때까지 then() 절의 콜백을 붙잡아 두라고 알려요. 그때가 되면 그 콜백을 실행하고, 요청의 결과를 인자로 전달해야 해요.

이벤트 루프에 비동기 이벤트가 추가되고 콜백을 붙잡아 두는 그림.

이 같은 모델이 일반적으로 Stream 객체 같은 Dart의 다른 모든 비동기 이벤트를 이벤트 루프가 처리하는 방식이에요.

비동기 프로그래밍 (Asynchronous programming)

이 절은 Dart의 비동기 프로그래밍에 쓰이는 다양한 타입과 문법을 요약해요. 이미 Future, Stream, async-await에 익숙하다면 isolates 절로 건너뛰어도 돼요.

Futures

Future는 결국 값이나 오류와 함께 완료될 비동기 작업의 결과를 나타내요.

다음 샘플 코드에서 Future<String>의 반환 타입은 결국 String 값(또는 오류)을 제공하겠다는 약속을 나타내요.

Future<String> _readFileAsync(String filename) {
  final file = File(filename);

  // .readAsString() returns a Future.
  // .then() registers a callback to be executed when `readAsString` resolves.
  return file.readAsString().then((contents) {
    return contents.trim();
  });
}

async-await 문법

asyncawait 키워드는 비동기 함수를 정의하고 그 결과를 사용하는 선언적인 방법을 제공해요.

파일 I/O를 기다리는 동안 블로킹되는 동기 코드의 예시를 볼게요.

const String filename = 'with_keys.json';

void main() {
  // Read some data.
  final fileData = _readFileSync();
  final jsonData = jsonDecode(fileData);

  // Use that data.
  print('Number of JSON keys: ${jsonData.length}');
}

String _readFileSync() {
  final file = File(filename);
  final contents = file.readAsStringSync();
  return contents.trim();
}

비슷하지만 비동기로 만드는 변경(하이라이트된 부분)이 있는 코드예요.

const String filename = 'with_keys.json';

void main() async {
  // Read some data.
  final fileData = await _readFileAsync();
  final jsonData = jsonDecode(fileData);

  // Use that data.
  print('Number of JSON keys: ${jsonData.length}');
}

Future<String> _readFileAsync() async {
  final file = File(filename);
  final contents = await file.readAsString();
  return contents.trim();
}

main() 함수는 _readFileAsync() 앞에 await 키워드를 사용해, 네이티브 코드(파일 I/O)가 실행되는 동안 다른 Dart 코드(이벤트 핸들러 같은)가 CPU를 쓸 수 있게 해요. await를 사용하면 _readFileAsync()가 반환하는 Future<String>String으로 변환하는 효과도 있어요. 그래서 contents 변수는 암시적으로 String 타입이 돼요.

정보

await 키워드는 함수 본문 앞에 async가 있는 함수에서만 동작해요.

아래 그림이 보여주듯, readAsString()이 Dart 런타임이나 운영 체제에서 non-Dart 코드를 실행하는 동안 Dart 코드는 멈춰요. readAsString()이 값을 반환하면 Dart 코드 실행이 재개돼요.

시작부터 종료까지 실행되며 중간에 네이티브 I/O를 기다리는 앱 코드를 보여주는 순서도 그림

Streams

Dart는 스트림 형태의 비동기 코드도 지원해요. 스트림은 미래에, 그리고 시간에 반복해서 값을 제공해요. 시간에 따라 일련의 int 값을 제공하겠다는 약속은 Stream<int> 타입을 가져요.

다음 예시에서 Stream.periodic으로 만든 스트림은 매초마다 새 int 값을 반복해 내보내요.

Stream<int> stream = Stream.periodic(const Duration(seconds: 1), (i) => i * i);
await-for와 yield

await-for는 새 값이 제공될 때마다 루프의 각 후속 반복을 실행하는 for 루프의 일종이에요. 즉 스트림을 '반복'하는 데 쓰여요. 이 예시에서 sumStream 함수는 인자로 제공된 스트림에서 새 값이 나올 때마다 새 값을 내보내요. 값의 스트림을 반환하는 함수에서는 return 대신 yield 키워드를 사용해요.

Stream<int> sumStream(Stream<int> stream) async* {
  var sum = 0;
  await for (final value in stream) {
    yield sum += value;
  }
}

async, await, Stream, Future 사용을 더 배우고 싶다면 비동기 프로그래밍 튜토리얼을 확인해 보세요.

Isolates

Dart는 비동기 API 외에도 isolate를 통한 동시성을 지원해요. 대부분의 현대 장치에는 멀티코어 CPU가 있어요. 여러 코어를 활용하기 위해 개발자들은 때때로 공유 메모리 스레드(shared-memory thread)를 동시에 실행하곤 해요. 하지만 공유 상태 동시성은 오류가 나기 쉽고 복잡한 코드로 이어질 수 있어요.

스레드 대신, 모든 Dart 코드는 isolate 안에서 실행돼요. isolate를 사용하면 Dart 코드가 사용 가능한 추가 프로세서 코어를 활용해 여러 독립적인 작업을 한 번에 수행할 수 있어요. isolate는 스레드나 프로세스와 비슷하지만, 각 isolate는 자신만의 메모리와 이벤트 루프를 실행하는 단일 스레드를 가져요.

각 isolate는 자신만의 전역 필드를 가져서, 어떤 isolate의 상태도 다른 isolate에서 접근할 수 없음이 보장돼요. isolate는 오직 메시지 전달을 통해서만 서로 소통할 수 있어요. isolate 사이에 공유 상태가 없다는 것은 뮤텍스나 락데이터 레이스 같은 동시성 복잡성이 Dart에서는 발생하지 않음을 뜻해요. 그렇다고 isolate가 경쟁 조건(race condition)을 완전히 막아주는 건 아니에요. 이 동시성 모델에 대해 더 알고 싶다면 Actor model을 읽어 보세요.

플랫폼 안내

isolate를 구현하는 것은 Dart Native 플랫폼뿐이에요. Dart 웹 플랫폼에 대해서는 웹에서의 동시성 절을 참고하세요.

main isolate

대부분의 경우 isolate에 대해 생각할 필요가 전혀 없어요. Dart 프로그램은 기본적으로 main isolate에서 실행돼요. 그것은 프로그램이 실행을 시작하고 진행하는 스레드예요. 아래 그림처럼요.

main isolate를 보여주는 그림: main()을 실행하고, 이벤트에 응답하고, 종료

단일 isolate 프로그램이라도 매끄럽게 실행될 수 있어요. 이런 앱들은 다음 코드 줄로 넘어가기 전에 async-await를 사용해 비동기 작업이 완료될 때까지 기다려요. 잘 동작하는 앱은 빠르게 시작해서 가능한 한 빨리 이벤트 루프에 도달해요. 그러면 앱은 필요할 때 비동기 작업을 사용하면서, 큐에 든 각 이벤트에 신속히 응답해요.

isolate 생명주기 (The isolate life cycle)

아래 그림처럼 모든 isolate는 main() 함수 같은 일부 Dart 코드를 실행하며 시작돼요. 이 Dart 코드는 예를 들어 사용자 입력이나 파일 I/O에 응답하기 위해 일부 이벤트 리스너를 등록할 수 있어요. isolate의 초기 함수가 반환될 때, isolate는 이벤트를 처리해야 한다면 남아 있어요. 이벤트를 처리한 후 isolate는 종료돼요.

어떤 isolate든 일부 코드를 실행하고, 선택적으로 이벤트에 응답하며, 종료되는 것을 보여주는 더 일반적인 그림

이벤트 처리 (Event handling)

클라이언트 앱에서 main isolate의 이벤트 큐에는 다시 그리기(repaint) 요청과 탭 및 기타 UI 이벤트의 알림이 들어 있을 수 있어요. 예를 들어 다음 그림은 다시 그리기 이벤트, 탭 이벤트, 그리고 다시 그리기 이벤트 두 개를 보여줘요. 이벤트 루프는 큐에서 선입선출(FIFO) 순서로 이벤트를 가져와요.

이벤트가 하나씩 이벤트 루프로 들어가는 그림

이벤트 처리는 main()이 종료된 후 main isolate에서 일어나요. 다음 그림에서 main()이 종료된 후, main isolate는 첫 번째 다시 그리기 이벤트를 처리해요. 그다음 main isolate는 탭 이벤트, 이어서 다시 그리기 이벤트를 처리해요.

동기 작업이 처리 시간을 너무 많이 쓰면 앱이 응답하지 않게 될 수 있어요. 다음 그림에서 탭 처리 코드가 너무 오래 걸려서 이후의 이벤트들이 너무 늦게 처리돼요. 앱이 멈춘 것처럼 보일 수 있고, 수행하는 애니메이션이 뚝뚝 끊기게 될 수 있어요.

실행 시간이 너무 긴 탭 핸들러를 보여주는 그림

클라이언트 앱에서 너무 긴 동기 작업의 결과는 흔히 끊기고 부드럽지 않은 UI 애니메이션(jank)으로 이어져요. 더 나쁘면 UI가 완전히 응답하지 않게 될 수 있어요.

백그라운드 워커 (Background workers)

앱의 UI가 시간이 많이 걸리는 계산—큰 JSON 파일 파싱 같은—때문에 응답하지 않게 된다면, 그 계산을 워커 isolate로 떠넘기는 걸 고려해 보세요. 흔히 백그라운드 워커(background worker) 라고 불러요.

아래 그림처럼 흔한 경우는, 계산을 수행하고 종료하는 단순한 워커 isolate를 스폰하는 거예요. 워커 isolate는 종료할 때 그 결과를 메시지로 반환해요.

main isolate와 단순한 워커 isolate를 보여주는 그림

워커 isolate는 I/O(예: 파일 읽고 쓰기)를 수행하고, 타이머를 설정하는 등의 일을 할 수 있어요. 자신만의 메모리를 가지며 main isolate와 어떤 상태도 공유하지 않아요. 워커 isolate는 다른 isolate에 영향을 주지 않고 블로킹될 수 있어요.

isolate 사용하기 (Using isolates)

Dart에서 isolate를 다루는 방법은 용례에 따라 두 가지가 있어요.

  • 별도의 스레드에서 단일 계산을 수행하려면 Isolate.run()을 사용해요.
  • 시간이 지나며 여러 메시지를 처리하거나 백그라운드 워커를 만들려면 Isolate.spawn()을 사용해요. 오래 살아있는 isolate를 다루는 방법에 대한 자세한 내용은 Isolates 페이지를 읽어 보세요.

대부분의 경우 Isolate.run이 백그라운드에서 프로세스를 실행하기 위한 권장 API예요.

Isolate.run()

정적 Isolate.run() 메서드는 한 개의 인자를 요구해요: 새로 스폰된 isolate에서 실행될 콜백이에요.

int slowFib(int n) => n <= 1 ? 1 : slowFib(n - 1) + slowFib(n - 2);

// Compute without blocking current isolate.
void fib40() async {
  var result = await Isolate.run(() => slowFib(40));
  print('Fib(40) = $result');
}

성능과 isolate 그룹 (Performance and isolate groups)

isolate가 Isolate.spawn()을 호출하면, 두 isolate는 같은 실행 코드를 가지며 같은 isolate 그룹(isolate group) 안에 있어요. isolate 그룹은 코드 공유 같은 성능 최적화를 가능하게 해요. 새 isolate는 isolate 그룹이 소유한 코드를 즉시 실행해요. 또한 Isolate.exit()는 isolate들이 같은 isolate 그룹에 있을 때만 동작해요.

몇몇 특수한 경우에는 Isolate.spawnUri()를 사용해야 할 수 있는데, 이는 지정된 URI에 있는 코드의 복사본으로 새 isolate를 설정해요. 하지만 spawnUri()spawn()보다 훨씬 느리고, 새 isolate는 spawn한 isolate의 isolate 그룹에 속하지 않아요. 또 다른 성능 영향은 isolate들이 서로 다른 그룹에 있으면 메시지 전달이 더 느려진다는 점이에요.

isolate의 한계 (Limitations of isolates)

isolate는 스레드가 아니에요 (Isolates aren't threads)

멀티스레딩 언어에서 Dart로 온 사람이라면 isolate가 스레드처럼 동작하길 기대하는 게 합리적이지만, 그렇지 않아요. 각 isolate는 자신만의 상태를 가져서 어떤 isolate의 상태도 다른 isolate에서 접근할 수 없음이 보장돼요. 따라서 isolate는 자신의 메모리에 대한 접근으로 제한돼요.

예를 들어 전역 가변 변수를 가진 애플리케이션이 있다면, 그 변수는 당신이 스폰한 isolate에서 별개의 변수가 돼요. 스폰된 isolate에서 그 변수를 변경해도 main isolate에서는 그대로 남아 있어요. isolate가 의도한 대로 동작하는 방식이 이거고, isolate 사용을 고려할 때 꼭 기억해야 해요.

메시지 타입 (Message types)

SendPort를 통해 보내는 메시지는 거의 모든 종류의 Dart 객체가 될 수 있지만, 몇 가지 예외가 있어요.

  • Socket 같은 네이티브 리소스를 가진 객체.
  • ReceivePort
  • DynamicLibrary
  • Finalizable
  • Finalizer
  • NativeFinalizer
  • Pointer
  • UserTag
  • @pragma('vm:isolate-unsendable')로 표시된 클래스의 인스턴스.

이 예외들을 제외하면 어떤 객체든 보낼 수 있어요. 더 많은 정보는 SendPort.send 문서를 확인해 보세요.

Isolate.spawn()Isolate.exit()SendPort 객체를 추상화하므로, 같은 한계를 받는다는 점을 기억하세요.

isolate 간 동기 블로킹 통신 (Synchronous blocking communication between isolates)

병렬로 실행될 수 있는 isolate의 수에는 제한이 있어요. 이 제한은 Dart에서 메시지를 통한 isolate 간 표준 비동기 통신에는 영향을 주지 않아요. 수백 개의 isolate를 동시에 실행하고 진행시킬 수 있어요. isolate들은 CPU에 라운드로빈 방식으로 스케줄링되고 자주 서로에게 양보해요.

isolate들은 순수 Dart 밖에서는, FFI를 통해 C 코드를 사용해야만 동기적으로 통신할 수 있어요. FFI 호출에서 동기 블로킹으로 isolate 사이의 동기 통신을 시도하면, 특별한 주의를 기울이지 않는 한 isolate 수가 한도를 넘을 때 교착 상태(deadlock)가 발생할 수 있어요. 이 한도는 특정 숫자로 하드코딩돼 있지 않고, Dart 애플리케이션에 사용 가능한 Dart VM 힙 크기에 따라 계산돼요.

이 상황을 피하려면 동기 블로킹을 수행하는 C 코드가 블로킹 작업을 하기 전에 현재 isolate를 떠나고, FFI 호출에서 Dart로 돌아오기 전에 다시 들어가야 해요. Dart_EnterIsolateDart_ExitIsolate에 대해 읽어 보세요.

웹에서의 동시성 (Concurrency on the web)

모든 Dart 앱은 비차단·인터리브 계산을 위해 async-await, Future, Stream을 사용할 수 있어요. 하지만 Dart 웹 플랫폼은 isolate를 지원하지 않아요. Dart 웹 앱은 웹 워커(web workers)를 사용해 isolate와 비슷하게 백그라운드 스레드에서 스크립트를 실행할 수 있어요. 다만 웹 워커의 기능과 능력은 isolate와 다소 달라요.

예를 들어 웹 워커가 스레드 간에 데이터를 보낼 때는 데이터를 앞뒤로 복사해요. 데이터 복사는 특히 큰 메시지의 경우 매우 느릴 수 있어요. isolate도 똑같이 하긴 하지만, 대신 메시지를 담고 있는 메모리를 더 효율적으로 전송(transfer) 할 수 있는 API도 제공해요.

웹 워커와 isolate를 만드는 방법도 달라요. 웹 워커는 별도의 프로그램 진입점(entrypoint)을 선언하고 그것을 따로 컴파일해야만 만들 수 있어요. 웹 워커를 시작하는 것은 isolate를 Isolate.spawnUri로 시작하는 것과 비슷해요. isolate는 스폰하는 isolate와 일부 같은 코드와 데이터를 재사용하기 때문에 더 적은 리소스가 필요한 Isolate.spawn으로도 시작할 수 있어요. 웹 워커에는 상당하는 API가 없어요.

추가 자료 (Additional resources)

  • isolate를 많이 사용한다면, Flutter의 IsolateNameServer나 non-Flutter Dart 애플리케이션에 유사한 기능을 제공하는 package:isolate_name_server를 고려해 보세요.
  • Dart의 isolate가 기반한 Actor model에 대해 더 읽어 보세요.
  • Isolate API에 대한 추가 문서:
    • Isolate.exit()
    • Isolate.spawn()
    • ReceivePort
    • SendPort

더 알아보기