우선 동기 비동기를 알아보기 전에 Dart 자바 처럼 multi thread 인지 아니면 typescript 처럼 single thread 인지 알아보자.
우선 dart 는 single thread 이나 그러나. 스트리밍, 백그라운드 작업도 가능한데 이는 Dart 는 Isolate 를 기반으로 동작하기 때문이다.
그럼 Isolate 가 뭔지 궁금할 텐데 이건 말 그대로 "독립된" 쓰레드 이다.
멀티 스레드인 자바와 비교하며 설명해주자면
멀티스레드: 하나의 프로세스 내에서 여러 개의 스레드가 병렬로 실행.
Isolate: 다트에서는 단일 스레드 모델을 사용하며, Isolate는 독립적인 실행 환경을 제공.
멀티스레드: 스레드 간에 메모리 공유.
Isolate: Isolate 간에는 메모리 공유 X. 각 Isolate는 자신의 메모리 공간을 가짐.
멀티스레드: 스레드 간 직접 메모리 접근으로 통신.
Isolate: Isolate 간 통신은 메시지 전송 방식을 사용.
자바: 경우 멀티스레드 프로그래밍에서 공유 메모리에 대한 동기화 문제가 발생할 수 있음.
다트: Isolate 간 메모리를 공유하지 않으므로, 이런 문제가 발생 X => 대신 각 Isolate 와 메시징으로 데이터를 교환
자바: 멀티스레드를 통해 병렬 처리를 할 수 있음.
다트: Isolate를 사용하여 병렬 처리를 할 수 있음. 각 Isolate는 독립적으로 실행되므로, CPU 코어를 효율적으로 활용할 수 있음.
자바: 스레드 수가 많아지면 오버헤드가 증가하여 성능이 저하될 수 있음.
다트: Isolate를 추가하여 확장성을 높일 수 있음. Isolate 간 메모리 공유가 없음.
자바: 멀티스레드 간 실행 환경이 공유됨.
다트: Isolate는 독립적인 실행 환경을 가지므로, 한 Isolate에서 문제가 발생해도 다른 Isolate에 영향을 미치지 않음.
정리하자면 다트가 갖고 있는 특이한 Isolate 방식은 메모리 안전성, 병렬 처리, 확성정이 있어 굉장히 효율과 성능이 좋다.
_isolate = await Isolate.spawn<SendPort>(
sleepApp, _receivePort.sendPort);
대충 이렇게 사용한다. 예제를 봐보자.
String? loadedData;
late Isolate _isolate;
final _receivePort = ReceivePort();
/// 리스너를 등록
/// _receivePort.sendPort를 인자로 전달해줘서 등록해준 리스너에 응답을 줄 수 있도록 함.
void initState() {
super.initState();
_receivePort.listen((message) {
setState(() {
loadedData = message.toString();
});
});
}
Widget build(BuildContext context) {
return MainFrame(
route: FeatureEnum.isolate,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Builder(builder: (_) {
if(loadedData == null){
return const SizedBox();
}
if(loadedData!.isEmpty){
return const CircularProgressIndicator();
}
return Text(loadedData!);
},),
const SizedBox(height: 10,),
ElevatedButton(onPressed: onTapMethodCall, child: const Text('Call Method')),
const SizedBox(height: 10,),
ElevatedButton(onPressed: onTapMethodCallWithIsolate, child: const Text('Call Method With Isolate')),
const SizedBox(height: 10,),
ElevatedButton(onPressed: onTapCancelIsolate, child: const Text('Cancel Isolate')),
],
),
);
}
이런 코드가 있다고 가정할 때
/// 아이솔레이트 없이 sleep을 호출함 -> 화면 멈춤
Future<void> onTapMethodCall() async {
setState(() {
loadedData = '';
});
sleep(const Duration(milliseconds: 5000));
setState(() {
loadedData = 'just sleep over';
});
}
/// 아이솔레이트를 통해 별도 메모리를 할당시켜 UI에 영향을 주지 않고 로직 실행
Future<void> onTapMethodCallWithIsolate() async {
setState(() {
loadedData = '';
});
_isolate = await Isolate.spawn<SendPort>(
sleepApp, _receivePort.sendPort);
}
/// 작업 중단
void onTapCancelIsolate(){
_isolate.kill(priority: Isolate.immediate);
setState(() {
loadedData = 'Task canceled';
});
}
void sleepApp(SendPort sendPort) {
sleep(const Duration(seconds: 5));
sendPort.send("Completion");
}
각각의 함수는 이렇게 생겼다고 하자.
onTapMethodCall
를 실행하면 1개의 thread 에서 sleep 과 렌더링을 모두 실행하므로 앱이 멈춰버린다.
대신 onTapMethodCallWithIsolate
를 사용하면 Isolate(격리된 메모리 공간)가 따로 돌아서 sleep 따로 rendering 따로 돌아간다.
주의
Isolate.spawn()에 등록해줄 함수는 Top-Level의 함수이거나 static 함수여야 한다는 점이다.
async / await / Future
통상 1 회만 응답을 돌려 받는 경우.
void somethingFn () {
print("TODO Done in right away");
}
Future<void> somethingFn (int second) async {
await Future.delayed(Duration(seconds: second));
print("TODO Done in $second seconds");
}
async* / yield / Stream
지속적으로 응답을 돌려 받는 경우.
Stream<int> somethingFn () async* {
int counter = 0;
while (counter <= 10) {
counter++;
await Future.delayed(Duration(seconds: 1));
print("TODO is running $counter");
yield counter;
}
print("TODO is Done");
}
somethingFn().listen((event){});
yield 는 뭔가 Stream 이 동작하는 과정에서 중간중간 당시 값을 return 해준다 라고 생각하면 되겠다.
통상 Stream Listener 나 Stream Builder 를 일반적으로 사용하는데 여기서는 그냥 간단한 예제로 대체하겠다.
참고로 Stream 은 청취하고 있는 객체가 없으면 바로 종료된다.