티스토리 뷰

반응형

다트에서 동시 프로그래밍이 어떻게 작동하는지에 대한 개념적 개요를 알아보자. 이 페이지에서는 이벤트 루프, 비동기 언어 기능 및 고수준의 아이솔레이트에 대해 설명한다. 

 

다트에서의 동시 프로그래밍은 Future 및 Stream과 같은 비동기 API와 아이솔레이트를 모두 의미한다. 이를 통해 프로세서를 별도의 코어로 이동할 수 있다.

 

모든 다트 코드는 아이솔레이트에서 실행되며, 기본적으로 기본 메인 아이솔레이트에서 시작하여 명시적으로 생성한 후속 아이솔레이트로 확장될 수 있다. 새로운 아이솔레이트를 생성하면 해당 아이솔레이트는 고립된 메모리와 고유한 이벤트 루프가 있다. 이벤트 루프는 다트에서 비동기 및 동시 프로그래밍을 가능하게 한다.

 

 

 

이벤트 루프

다트의 런타임 모델은 이벤트 루프를 기반으로 한다. 이벤트 루프는 프로그램 코드를 실행하고 이벤트를 수집하고 처리하는 등의 작업을 담당한다.

 

애플리케이션이 실행되는 동안 모든 이벤트는 이벤트 큐라고 불리는 큐에 추가된다. 이벤트는 UI를 다시 그리는 요청부터 사용자 탭 및 키 입력, 디스크에서의 입출력 등 무엇이든 될 수 있다. 앱이 어떤 순서로 이벤트가 발생할지 예측할 수 없기 때문에 이벤트 루프는 이벤트가 큐에 추가된 순서대로 하나씩 처리한다. 

 

그러나 대부분의 다트 애플리케이션은 동시에 한 가지 이상의 작업을 수행해야 한다. 예를 들어 클라이언트 애플리케이션은 HTTP 요청을 실행해야 하면서도 사용자가 버튼을 탭하는 것을 감지하고 있어야 한다. 이를 처리하기 위해 다트는 Future, Stream 및 async-await와 같은 비동기 API를 제공한다. 이러한 API는 이벤트 루프 기반으로 구축되었다. 

 

예를 들어, 다음은 네트워크 요청을 처리하는 코드이다.

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

 

위 코드가 이벤트 루프에 도달하면 http.get()을 즉시 호출하고 Future를 반환한다. 또한 HTTP 요청이 해결될 때까지 then() 절의 콜백을 이벤트 루프에 보관하고, HTTP 요청이 완료되면 해당 콜백을 실행하고 요청의 결과를 인수를 response로 전달한다.

 

비동기 프로그래밍

다트에서 비동기 프로그래밍의 다양한 타입과 구문을 요약한다.

 

Future

Future는 비동기 작업의 결과를 나타내며, 이 작업은 결국 값이나 오류로 완료된다.

 

다음 예제 코드에서 Future<String> 타입은 String 값, 또는 오류를 최종적으로 제공하겠다는 약속을 의미한다.

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

  // .readAsString()은 Future를 반환합니다.
  // .then()은 `readAsString`가 해결될 때 실행할 콜백을 등록합니다.
  return file.readAsString().then((contents) {
    return contents.trim();
  });
}

 

 

async-await 구문

async와 await 키워드는 비동기 함수를 정의하고 해당 결과를 사용하는 선언적인 방법을 제공한다.

 

다음은 파일 입출력을 기다리는 동안 차단되는 동기적 코드의 예제이다.

const String filename = 'with_keys.json';

void main() {
  // 데이터를 읽습니다.
  final fileData = _readFileSync();
  final jsonData = jsonDecode(fileData);

  // 해당 데이터를 사용합니다.
  print('JSON 키의 수: ${jsonData.length}');
}

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

 

다음 코드는 async와 await를 사용해서 비동기적으로 변경된 버전이다. 

const String filename = 'with_keys.json';

void main() async {
  // 데이터를 읽습니다.
  final fileData = await _readFileAsync();
  final jsonData = jsonDecode(fileData);

  // 해당 데이터를 사용합니다.
  print('JSON 키의 수: ${jsonData.length}');
}

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

 

main() 함수는 await 키워드를 _readFileAsync() 앞에 사용하여 _readFileAsync()가 완료될 때까지 다른 다트 코드가 CPU를 사용할 수 있도록 한다. 즉, await를 사용하면 _readFileAsync()가 반환하는 Future<String>을 String으로 변환하는 효과를 가진다. 따라서 contents 변수는 암시적으로 String 타입이 된다.

 

참고로 await 키워드는 함수 바디 앞에 async가 있는 바디에서만 작동한다.

 

다음 그림에서 보듯이 await를 사용한 다트 코드는 raedAsString()이 다트 런타임 또는 운영체제에서 작동하는 동안 일시 중단된다. 그리고 readAsString()이 값을 반환하면 이어서 다트 코드 실행이 시작된다. 

 

 

Stream

다트는 스트림(Stream) 타입의 비동기 코드도 지원한다. 스트림은 미래에 값들을 제공하고 시간이 지남에 따라 반복적으로 값을 제공한다. 시간이 지남에 따라 일련의 int 값을 제공할 것이라는 약속은 Stream<int> 타입이 된다.

 

다음예제에서는 Stream.periodic로 생성된 스트림이 1초마다 새로운 int 값을 반복적으로 발생시킨다.

Stream<int> stream = Stream.periodic(
  const Duration(seconds: 1), (i) => i * i
);

 

 

await-for 및 yield

await-for은 새로운 값이 제공될 때마다 루프의 바디를 실행하는 for 루프의 한 종류이다. 즉, 스트림을 반복할 때 사용된다. 다음 예제에서 sumStream 함수는 인자로 제공된 스트림에서 새로운 값이 나올 때마다 새로운 값을 반환한다. 스트림을 반환하는 함수에서는 return 대신 yield를 사용하고 바디 앞에 async*를 사용해야 한다.

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

 

 

 

아이솔레이트(isolate)

다트는 비동기 API뿐만 아니라 아이솔레이트(isolate)를 통해 동시성을 지원한다. 대부분의 현대 장치는 멀티 코어 CPU를 가지고 있다. 여러 코어를 활용하기 위해 개발자들은 종종 공유 메모리를 사용하는 스레드를 동시 실행합니다. 그러나 이런 공유 상태 동시성은 오류 발생 가능성이 높고 코드가 복잡해질 수 있다.

 

스레드 대신, 모든 다트 코드는 아이솔레이트 내에서 실행됩니다. 아이솔레이트를 사용하면 다트 코드는 여러 독립적인 작업을 동시에 수행할 수 있으며, 추가적인 프로세서 코어가 사용 가능할 경우 이를 활용할 수 있다. 아이솔레이트는 스레드나 프로세스와 유사하지만, 각 아이솔레이트는 자체 메모리를 가지고 있으며 이벤트 루프를 실행하는 단일 스레드를 가진다.

 

각 아이솔레이트는 자체 전역 필드를 가지며, 다른 아이솔레이트에서 해당 상태에 접근할 수 없도록 보장한다. 아이솔레이트는 메시지 전달을 통해서만 서로 통신할 수 있다. 아이솔레이트 간에 공유 상태가 없기 때문에 다트에서는 뮤텍스(mutex)나 잠금(lock) 같은 동시성 복잡성이나 데이터 경합이 발생하지 않는다. 그렇다고 해서 아이솔레이트가 경합 조건을 완전히 방지하는 것은 아니므로 조심해야 한다.

 

다트 네이티브 플랫폼만 아이솔레이트를 구현할 수 있다. 다트 웹 플랫폼은 웹에서의 동시성 섹션을 하자.

 

 

 

메인 아이솔레이트

대부분의 경우, 아이솔레이트에 대해 전혀 생각할 필요가 없다. 다트 프로그램은 기본적으로 메인 아이솔레이트에서 실행된다. 이는 프로그램이 실행되고 실행을 시작하는 스레드이며, 다음 그림과 같이 작동한다.

 

단일 아이솔레이트 프로그램도 원할하게 실행될 수 있다. 이러한 애플리케이션은 비동기 작업이 완료될 때까지 async-await를 사용하여 다음 코드 줄로 계속 진행된다. 잘 작동하는 애플리케이션은 빠르게 시작하여 가능한 빨리 이벤트 루프로 이동한다. 그럼 다음 각 큐에 대기 중인 각 이벤트에 신속하게 응답하고, 필요한 경우 비동기 작업을 사용한다.

 

 

 

아이솔레이트의 생명주기 

다음 그림처럼 모든 아이솔레이트는 main() 함수와 같은 다트 코드를 실행하여 시작된다. 이 다트 코드는 사용자 입력 또는 파일 입출력에 응답하기 위해 이벤트 리스너를 등록할 수 있다. 아이솔레이트는 이벤트를 처리해야 할 필요가 있는 한은 계속 유지된다. 이벤트를 다 처리한 후에는 아이솔레이트는 종료된다.

 

 

이벤트 처리

클라이언트 애플리케이션에서 메인 아이솔레이트의 이벤트 큐에는 리페인트(다시 그리기) 요청 및 탭 등의 UI 이벤트 알림이 포함될 수 있다. 예를 들어, 다음 그림은 이벤트 큐에 리베인트 이벤트, 탭 이벤트가 포함되어 있는 것을 보여준다. 이벤트 루프는 큐에 처음 들어온 것을 처음 처리하는 선입선출 방식으로 이벤트를 처리한다.

 

이벤트 처리는 main()이 종료된 후에 메인 아이솔레이트에서 발생한다. 다음 그림에서 main()이 종료된 후 메인 아이솔레이트는 첫 번째 리페인트 이벤트를 처리한다. 그 후 아이솔레이트는 탭 이벤트를 처리하고 이어서 리페인트 이벤트를 처리한다.

 

동기 작업이 너무 많은 처리 시간을 소요하면 애플리케이션이 응답하지 않게 될 수 있다. 다음 그림에서 탭 처리 코드가 너무 오래 실행되면 이후 이벤트가 늦게 처리된다. 그러면 애플리케이션이 멈춘 것처럼 보일 수 있으며, 수행 중인 애니메이션이 끊길 수 있다.

클라이언트 애플리케이션에서 동기 작업이 너무 길어지면 UI 애니메이션이 자주 끊기게 된다. 더 나쁜 경우, UI가 완전히 응답하지 않게 될 수 있다.

 

 

백그라운드 작업자

시간이 많이 걸리는 연산으로 인해 앱의 UI가 응답하지 않게 된다면(예: 용량이 큰 JSON 파일을 파싱할 때), 그 연산을 백그라운드 작업자(background worker)라고 불리는 작업자 아이솔레이트로 오프로드하는 것을 고려해 보자. 일반적인 경우에는 다음 그림처럼 간단한 작업자 아이솔레이트를 생성하여 연산을 수행하고 종료하는 것이다. 작업자 아이솔레이트는 종료 시 메시지로 결과를 반환한다.

작업자 아이솔레이트는 입출력 작업(예: 파일 읽기 및 쓰기), 타이머 설정 등을 수행할 수 있다. 작업자 아이솔레이트는 자체 메모리를 가지고 있으며 메인 아이솔레이트와 상태를 공유하지 않는다. 작업자 아이솔레이트는 다른 아이솔레이트에 영향을 주지 않고 차단될 수 있다.

 

 

 

아이솔레이트 사용

사용 사례에 따라 다트에서 아이솔레이트를 사용하는 두 가지 방법이 있다.

  • Isolate.run()을 사용하여 별도의 스레드에서 단일 연산을 수행한다.
  • Isolate.spawn()을 사용하여 시간이 지남에 따라 여러 메시지를 처리하거나 백그라운드 작업자를 생성한다.

오래 실행되는 아이솔레이트를 사용하는 방법에 대한 자세한 내용은 '아이솔레이트' 페이지를 참조하자. 대부분의 경우, Isolate.run()이 백그라운드에서 프로세스를 실행하기 위한 권장 방법이다.

 

 

Isolate.run()

정적 Isolate.run() 메서드는 하나의 인수를 필요로 한다. 이는 새로 생성된 아이솔레이트에서 실행될 함수가 된다.

 

다음 코드는 slowFib를 새로운 아이솔레이트에서 작동하는 코드가 된다. 따라서 slowFib가 비동기로 작동하므로 메인 아이솔레이트의 응답성이 유지된다.

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

// 현재 아이솔레이트를 차단하지 않고 계산한다.
void fib40() async {ac
  var result = await Isolate.run(() => slowFib(40));
  print('Fib(40) = $result');
}

 

 

성능 및 아이솔레이트 그룹

Isolate.spawn()이 호출될 때, 두 아이솔레이트는 동일한 실행 코드를 가지며 동일한 아이솔레이트 그룹에 속한다. 아이솔레이트 그룹은 코드 공유와 같은 성능 최적화를 가능하게 한다. 새 아이솔레이트는 아이솔레이트 그룹이 소유한 코드를 즉시 실행한다. 또한 Isolate.exit()는 아이솔레이트가 동일한 아이솔레이트 그룹에 있을 때만 작동한다.

 

특정 경우에는 Isolate.spawnUri()를 사용해야 할 수 있는데, 이는 지정된 URI에 있는 코드를 복사하여 새로운 아이솔레이트를 설정한다. 그러나 spawnUri()는 spawn()보다 훨씬 느리며, 새로운 아이솔레이트는 새로운 아이솔레이트 그룹에 속하지 않는다. 또 다른 성능 결과는 서로 다른 그룹에 있는 아이솔레이트 간에 메시지를 전달할 때 속도가 느려진다는 점이다.

 

 

 

아이솔레이트의 제한 사항

아이솔레이트는 스레드가 아니다

멀티스레딩이 있는 언어를 사용했다면, 다트의 아이솔레이트가 스레드처럼 동작할 것으로 기대할 수 있다. 하지만 각 아이솔레이트는 자체 상태를 가지고 있고, 다른 아이솔레이트에서 해당 상태에 접근할 수 없다.

예를 들어, 전역 변경 가능한 변수가 있는 애플리케이션이 있다면, 그 변수는 생성된 아이솔레이트에만 존재하게 된다. 이는 아이솔레이트가 의도된 방식으로 동작하는 것이며, 아이솔레이트를 사용 시 이를 염두에 두는 것이 중요하다.

 

 

 

웹에서의 동시성

모든 다트 애프리케이션은 async-await, Future, Stream을 사용할 수 있다. 그러나 다트 웹 플랫폼은 아이솔레이트를 지원하지 않는다. 다트 웹 애플리케이션은 아이솔레이트와 유사한 스크립트를 백그라운 스레드에서 실행하는 웹 워커(web worker)를 사용할 수 있다. 웹 워크의 기능과 역량은 아이솔레이트와 다소 다르다.

 

 

 

추가 자료

  • 많은 아이솔레이트를 사용 중이라면 플러터의 IsolateNameServer 또는 플러터가 아닌 다트 애플리케이션에 대해 유사한 기능을 제공하는 package:isolate_name_server를 고려해 보자.
  • 다트의 아이솔레이트가 기반한 액터 모델에 대해 더 읽어보자.
  • Isolate API에 대한 추가 문서를 읽어보자.
    • Isolate.exit()
    • Isolate.spawn()
    • ReceiveProt
    • SendPort

 

 

반응형
댓글
공지사항