[Dart] Completer를 사용한 비동기 제어

2025. 1. 15. 13:27Programming/Flutter

반응형
 

[Dart] 메시지를 통해 동작하는 Isolate를 추상화하기

Isolate에 대해 알아보자에서 Isolate를 사용할 시 유의해야 할 점과, 포트를 사용해서 Isolate 사이에 여러개의 메시지를 주고받기에 기재되어있는 예제를 살펴봤습니다. 유의할 점 중에는 Isolate 갯

zerodice0.tistory.com

  이전에 여러가지 동작을 하나의 Isolate에서 처리 가능하도록 작성한 적이 있었다. 이를 통해서 Worker를 1~2개만 생성한 뒤 원하는 작업이 별개의 Isolate에서 동작하도록 작성할 수 있게 됐다. 물론 앱의 성능을 위해서도 Isolate는 1~2개 정도가 실행되는게 가장 적당했는데, 어느 순간 확인해보니 Worker가 4~8개씩 실행되는 경우가 있는게 아닌가. 어디가 문제인지 고민하다보니, 답은 하나밖에 없었다. 바로 static으로 선언해놓은 _worker가 null인 경우 새로운 isolator를 생성하는 코드가 문제였던 것이다.

static Future<Worker> getWorker() async {
  debugPrint('getWorker (_worker: ${_worker != null})');
  return _worker ??= await Worker.spawn(handleTaskId);
}

  싱글톤으로 동작할 거라고 생각했던 getWorker()에 남겨둔 debugPrint() 구문에서는, _worker가 null일 때 여러번 실행되면 'getWorker (_worker: false)'를 연속으로 출력하고 있었다. Dart 자체는 싱글스레드로 동작하니까 문제 없을거라고 생각했지만, 이벤트 루프에 의한 스케쥴링을 우습게 본 결과였다. 그러니까 위와 같은 코드는 async-await 키워드와 상관없이 getWorker()에 대한 동작으로 _worker에 대한 판별이 발생하며, 이 값이 null인 경우에 Worker.spawn()를 이벤트 큐에 넣고 스케쥴링하게 된다. 결과적으로는 기대했던 것과는 달리, 코드에 따라서 Worker.spawn()이 여러번 실행되며, _worker에 할당되어 제어 가능한 Isolate 이외에는 쓸데없이 메모리만 차지하고 있게 된다. 하는 게 없으니까 성능에 지대한 영향을 미치지는 않겠지만, 아무튼 불필요한 프로세스가 자원을 잡아먹고 있다는 사실은 문제상황이다.

 

Completer란?

문서를 살펴보면 Completer는 다음과 같이 설명되고 있다. 'A way to produce Future objects and to complete them later with a value or error.', 대충 'Future 객체를 생성한 뒤, 나중에 값 혹은 에러로 완료할 수 있는 방법'이라는 내용이 되겠다. 처음 봤을때는 이게 뭔 소린가 싶었는데, 간단하게 정리하면 다음과 같다.

  • Completer 객체는 Future 객체를 생성한다.
  • Completer 객체는 complete() 혹은 completeError()를 호출함으로써, 생성한 Future 객체를 값 혹은 에러로 완료할 수 있다. 즉, complete() 혹은 completeError()를 호출함으로써 비동기 작업을 처리한 결과를 결정한다.
  • Completer 객체가 값 혹은 에러로 완료하는 Future값은 Completer 객체 내부에 future라는 값으로 저장되어있다. 즉, Completer에서 아직 complete() 혹은 completeError()가 호출되기 전에 동일한 future가 완료되기를 원한다면, Completer 객체의 future를 참조하고 있다가 complete() 혹은 completeError()가 호출된 시점에 동일한 값 혹은 에러를 가지고 처리가 가능하다. 
 

Completer class - dart:async library - Dart API

A way to produce Future objects and to complete them later with a value or error. Most of the time, the simplest way to create a future is to just use one of the Future constructors to capture the result of a single asynchronous computation: Future(() { do

api.flutter.dev

얘기가 조금은 빙글빙글 도는 느낌이지만, 실제로 구현한 얘를 살펴보면 좀 더 쉽게 이해할 수 있다. 위의 getWorker() 메서드에 Completer를 적용해서 문제를 해결하려면, 다음과 같이 작성할 수 있다.

static Worker? _worker;
static Completer<Worker>? _workerCompleter;

static Future<Worker> getWorker() async {
  // 1. _worker가 null이 아니라면 이미 초기화된 worker가 
  // 있다는 얘기가 되므로, _worker를 반환한다.
  if (_worker != null) {
    return _worker!;
  }

  // 2. _workerCompleter가 null이 아니라면 _worker는 초기화되지 않았지만, 
  // 이미 Worker.spawn()이 이벤트 루프에 의해서 비동기 처리중이라는 얘기가 되므로
  // _workerCompleter.future를 반환한다. _workerCompleter.future를 반환받으면
  // complete()/completeError()가 호출됐을 때 처리가 완료된 값/에러를
  // 가지고 작업을 수행하게 된다.
  if (_workerCompleter != null) {
    return _workerCompleter!.future;
  }

  // 3. _worker도 null이고 _workerCompleter도 null이라면
  // Worker.spawn() 함수를 호출해서 비동기 처리를 진행한다. 이 Future는
  // 이벤트 루프에 의해 스케쥴링된다.
  // 그리고 await에 의해 Worker.spawn()이 완료되면 _workerCompleter.complete()를
  // 호출해서, _workerCompleter.future를 반환받은 위치에서 대기하고 있는 Future 역시
  // 완료되도록 처리해준다.
  _workerCompleter = Completer<Worker>();

  try {
    _worker = await Worker.spawn(handleTaskId); // Future 생성 및 비동기 처리
    _workerCompleter!.complete(_worker!); // complete() 호출로 Future가 완료됐음을 알림
    return _worker!;
  } catch (e) {
    _workerCompleter!.completeError(e); // catch 구문에서 completeError() 호출로 Future에서 에러가 발생했음을 알림
    _workerCompleter = null;
    rethrow; // 에러를 호출자에게 전달
  } finally {
    _workerCompleter = null;
  }
}

주석에도 남겨뒀지만 getWorker()가 실행되는 방식은 다음과 같다.

  1. _worker가 null이 아니라면 이미 초기화된 worker가 있다는 얘기가 되므로, _worker를 반환한다.
  2. _workerCompleter가 null이 아니라면 _worker는 초기화되지 않았지만, 이미 Worker.spawn()이 이벤트 루프에 의해서 비동기 처리중이라는 얘기가 되므로 _workerCompleter.future를 반환한다. _workerCompleter.future를 반환받으면 complete()/completeError()가 호출됐을 때 처리가 완료된 값/에러를 가지고 작업을 수행하게 된다.
  3. _worker도 null이고 _workerCompleter도 null이라면 Worker.spawn() 함수를 호출해서 비동기 처리를 진행한다. 이 Future는 이벤트 루프에 의해 스케쥴링된다. 그리고 await에 의해 Worker.spawn()이 완료되면 _workerCompleter.complete()를 호출해서, _workerCompleter.future를 반환받은 위치에서 대기하고 있는 Future 역시 완료되도록 처리해준다.

즉, Completer 객체는 비동기에 의해서 생성되는 게 아니므로, 여러개의 getWorker()가 동시다발적으로 호출됐을 때 처음 호출된 getWorker()는 Completer<Worker> 객체를 생성함과 동시에 비동기 작업인 Worker.spawn()을 호출하게 된다. 그리고 그 이외의 getWorker()는 이미 선언된 _workerCompleter의 future를 참조하게 된다. 처음 호출한 getWorker()에서 실행한 Worker.spawn()이 이벤트 루프에 의해서 처리되면, _workerCompleter.complete()가 실행되어 나머지 getWorker()에서도 동일한 Worker.spawn()의 결과값 혹은 에러값을 가지고 처리하게 된다. 

 

그림으로 정리하면 아래와 같다. 다음은 getWorker()가 동시에 세 번 호출됐고, 각각 호출된 지점을 caller A, caller B, caller C라고 지칭했을 때, Completer<Worker>에 의해 경쟁 상태를 제거하고 각각 호출된 지점에서 동일한 Worker 객체를 반환받는 과정이다. 여기서는 _worker와 _workerCompleter가 모두 null이어서 Worker.spawn()을 직접 호출하는 지점을 Caller A, 그리고 _worker는 null이지만 _workerCompleter가 null이 아니라서 _workerCompleter.future가 완료되기를 대기하는 지점을 Caller B, Caller C라고 간주한다.

동시에 세 번의 getWorker()가 호출됐을 때 Completer 객체와 Future 객체가 동작하는 흐름도.

핵심은 2번과 3번에서 Caller B와 Caller C가 각각 _worker 객체를 만들기 위한 Worker.spawn()이 호출됐다는 것을 인지하고, _workerCompleter.future가 완료되기를 대기하는 부분이 되겠다. 결과적으로 가장 처음 Worker.spawn()을 호출했던 Caller A가 _workerCompleter.complete() 혹은 _workerCompleter.completeError()를 호출하면, Caller B와 Caller C에서 참조하게되는 _workerCompleter.future가 완료되어 결과값인 _worker 객체를 반환받게 되는 셈이다.

 

이것으로 Dart 자체는 싱글 스레드지만 이벤트 루프를 통해 작업 스케쥴링을 진행하므로, Future를 사용해서 비동기 처리를 할 때 경쟁 상태에 빠질 수 있다는 점과, Completer를 사용해서 이러한 경쟁 상태를 해결할 수 있다는 점을 살펴봤다. 이를 좀 더 간단하게 처리할 수 있도록 도와주는 Synchronized 패키지도 존재하므로, 외부 패키지에 대한 의존성이 문제가 되지 않는다면 Completer를 사용하지 않고 외부 패키지를 사용해서 적절하게 처리할 수 있으니 참고하도록 하자.


* 참고:

- https://api.flutter.dev/flutter/dart-async/Completer-class.html

Dart API 문서 중dart:async 라이브러리에 소개되어있는 Completer class

 

 

Completer class - dart:async library - Dart API

A way to produce Future objects and to complete them later with a value or error. Most of the time, the simplest way to create a future is to just use one of the Future constructors to capture the result of a single asynchronous computation: Future(() { do

api.flutter.dev

- https://pub.dev/packages/synchronized/

Pub.dev의 synchronized 패키지

 

synchronized example | Dart package

Lock mechanism to prevent concurrent access to asynchronous code.

pub.dev

- https://dart.dev/libraries/async/async-await

Dart 공식 문서의 비동기 프로그래밍 가이드

 

Asynchronous programming: futures, async, await

Learn about and practice writing asynchronous code in DartPad!

dart.dev

 

 

 

반응형