(Flutter) 멀티스레딩 분리

 

Dart언어를 사용하는 Flutter 플랫폼에서 실행되는 코드는 기본적으로 격리된 메모리 공간에서 단일 스레드 환경으로 이벤트 루프가 실행됩니다.
그렇기 때문에 시간이 오래걸리는 작업 처리시 스레드가 하나로 처리되는데 해당 작업을 처리하는 시간동안은 이벤트 루프가 동작되지 않아 UI 업데이트에 문제가 생기거나 버벅거릴 수 있습니다.
Flutter는 화면의 자연스러움을 유지 하기 위해 최소 1ms에 60프레임을 처리하려고 합니다. 이런 요청을 유지 할 수 있도록 이벤트 루프에서 메세지 펌핑이 원활하게 되도록 처리하는 것이 중요합니다.

이러한 격리 모델은 Dart언어에서는 기본 구조로 구축되었지만 Dart가 실행되는 VM의 OS에 따라 스레드를 제공한다면 멀티 스레딩을 활용하여(정확히 말하자면 격리된 단일 스레드 환경을 새로 추가하여 활용) 작업을 ‘분리’해서 처리할 수 있도록 지원하고 있습니다.
이 글에서는 추가 격리공간을 만들고 격리된 작업간 데이터 전달 방법에 대해 알아보도록 하겠습니다.

Isolate 란?

Flutter의 dart:isolate.Isolate 는 격리된 장소에서 처리되는 별도의 메모리 공간을 갖습니다. 그렇기 때문에 다른 격리 공간과 데이터를 공유할 수 없습니다.
이러한 이점은 임계영역에 대한 문제가 발생하지 않다는 이점이 있습니다. 또한 분리된 메모리 공간에서 단일 스레드로 처리 되며 각 dart:isolate.Isolate 간의 데이터 전달은 메세지 통신을 사용 합니다.
메세지 통신은 dart:isolate.ReceivePortdart:isolate.SendPort 로 메세지를 주고 받을 수 있고 multiple receivePort를 사용해서 다중 수신 처리가 가능합니다.

Compute 사용

_isolates_io.compute() 함수를 통해 새로운 Isolate를 추가해서 멀티 스레딩으로 처리할 수 있습니다.
우선 예제를 일반 동기 방식으로 호출되는 코드와 _isolates_io.compute() 함수를 통해 dart:isolate.Isolate 로 호출하고 결과 데이터를 받아 처리하는 방법 두가지로 작성해 보았습니다.

class _ComputeTestState extends State<ComputeTest> {
  String? _taskResult;

  // 오래걸리는 작업 함수 [Isolate의 endpoint 함수는 최상위(Top-level) 함수이거나 static함수여야 한다., Compute도 동일]
  static String _expensiveTask(int sec) {
    sleep(Duration(seconds: sec));
    return "Completion";
  }

  Widget _taskResultWidget() {
    if (_taskResult == null) {
      return Container();
    } else if (_taskResult != null && _taskResult.toString().isEmpty) {
      return const CircularProgressIndicator();
    } else {
      return Text(
        '결과 : $_taskResult',
        style: const TextStyle(fontSize: 25),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
            title: const Text(
          'Compute Test',
          style: TextStyle(fontSize: 20),
        )),
        body: Center(
          child: Column(children: [
            const SizedBox(height: 10),

            _taskResultWidget(),

            const SizedBox(height: 10),

            // 일반 동기로 작업 시작
            ElevatedButton(
                onPressed: () async {
                  setState(() {
                    _taskResult = '';
                    _taskResult = _expensiveTask(5);
                  });
                },
                child: const Text(
                  'Start task with Sync',
                  style: TextStyle(fontSize: 17),
                )),

            const SizedBox(height: 10),

            // Compute로 작업 시작
            ElevatedButton(
                onPressed: () async {
                  setState(() {
                    _taskResult = '';
                  });

                  final taskResult = await compute(_expensiveTask, 5);

                  setState(() {
                    _taskResult = taskResult;
                  });
                },
                child: const Text(
                  'Start task with Compute',
                  style: TextStyle(fontSize: 17),
                ))
          ]),
        ));
  }
}

코드를 보면 sleep하는 함수를 실행하는 두개의 버튼이 있고 각각 동기 호출과 _isolates_io.compute() 함수를 사용한 호출 방식으로 되어 있습니다.
[Start task with Sync] 버튼을 누르면 약 5초 후 결과 데이터가 화면에 출력되는데 그 사이에는 전체 App이 멈추고 표시 되는걸 확인해볼 수 있고
[Start task with Compute] 버튼을 누르면 결과는 동일하지만 App은 멈추지 않고 로딩 처리까지 되는걸 확인해 볼 수 있습니다.
코드를 보면 _isolates_io.compute() 함수의 사용은 단순합니다.
별도 스레드로 처리될 함수 endPoint와 해당 함수에서 사용되는 파라메터값을 전달하고 await로 결과 데이터를 받아 처리할 수 있습니다.

함수 파라메터는 단일로만 받기 때문에 만약 여러 파라메터를 넘겨야 하는 경우 List<Object> 타입같은 배열 형태로 전달하고 캐스팅해서 사용 합니다. (Isolate.spawn 예제에서 사용됩니다.)

그리고 endPoint 함수는 최상위(Top-level) 함수이거나 static함수여야 합니다.

Isolate.spawn 사용

dart:isolate.Isolate 클래스로 처리되는 작업을 중지 시키거나 데이터를 여러 곳에서 구독해야 하는 경우 _isolate_io.compute() 함수는 지원되지 않아 처리할 수 있습니다.
_isolates_io.compute() 함수는 내부적으로 dart:isolate.Isolate 클래스를 래핑해서 단순하게 제공되는데 dart:isolate.Isolate.spawn<T> 함수는 작업 취소 요청 등을 좀 더 커스텀하게 직접 처리가 가능합니다.

다음 예제 코드는 dart:isolate.Isolate.spawn<T> 함수를 통해 dart:isolate.Isolate 를 생성하고 dart:isolate.ReceivePortdart:isolate.SendPort 로 메세지를 전달 및 작업 취소 처리 예제 입니다.

// 오래걸리는 작업 함수 [Isolate의 endpoint 함수는 최상위(Top-level) 함수이거나 static함수여야 한다., Compute도 동일]
void expensiveTask(List<Object> param) {
  int sec = param[0] as int;
  SendPort sendPort = param[1] as SendPort;

  sleep(Duration(seconds: sec));
  sendPort.send("Completion");
}

class _IsolateTestState extends State<IsolateTest> {
  String? _taskResult;
  late Isolate _isolate;
  final _receivePort = ReceivePort();

  @override
  void initState() {
    super.initState();

    _receivePort.listen((message) {
      setState(() {
        _taskResult = message.toString();
      });
    });
  }

  Widget _taskResultWidget() {
    if (_taskResult == null) {
      return Container();
    } else if (_taskResult != null && _taskResult.toString().isEmpty) {
      return const CircularProgressIndicator();
    } else {
      return Text(
        '결과 : $_taskResult',
        style: const TextStyle(fontSize: 25),
      );
    }
  }

  @override
  void dispose() {
    _receivePort.close();
    _isolate.kill(priority: Isolate.immediate);
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
            title: const Text(
          'IsolateTest',
          style: TextStyle(fontSize: 20),
        )),
        body: Center(
          child: Column(children: [
            const SizedBox(height: 10),

            _taskResultWidget(),

            const SizedBox(height: 10),

            // Isolate로 작업 시작
            ElevatedButton(
                onPressed: () async {
                  setState(() {
                    _taskResult = '';
                  });

                  _isolate = await Isolate.spawn<List<Object>>(
                      expensiveTask, [5, _receivePort.sendPort]);
                },
                child: const Text(
                  'Start task with Isolate',
                  style: TextStyle(fontSize: 17),
                )),

            const SizedBox(height: 10),

            // 작업 취소 (Isolate로)
            ElevatedButton(
                onPressed: () {
                  _isolate.kill(priority: Isolate.immediate);

                  setState(() {
                    _taskResult = 'Task canceled';
                  });
                },
                child: const Text(
                  'Cancel task',
                  style: TextStyle(fontSize: 17),
                ))
          ]),
        ));
  }
}

[Start task with Isolate] 버튼은 dart:isolate.Isolate.spawn<T> 함수로 새로운 격리공간에서 시간이 오래 걸리는 작업을 수행처리 합니다.
expensiveTask() 함수는 List<Object>로 배열을 받고 있는데 sleep처리의 시간과 dart:isolate.SendPort 를 받아 처리하는데, dart:isolate.ReceivePort 에서 제공되는 dart:isolate.SendPort 를 전달합니다.

데이터 수신은 dart:isolate.ReceivePort 클래스의 dart:isolate.ReceivePort.listen() 함수로 Stream을 구독해 데이터를 수신 받을 수 있습니다.
그리고 격리공간에서는 dart:isolate.SendPort.send() 함수로 데이터를 전송합니다.

dart:isolate.ReceivePort.listen() 은 단일 구독으로 처리 되기 때문에 initState()에서 한번 구독 처리로 처리하였습니다.
이는 Flutter에서 다른 Stream구독시에도 마찬가지이며 여러번 구독되는 경우 ‘Bad state: Stream has already been listened to.’ 오류가 발생됩니다.

다음으로 [Task canceled] 버튼으로 작업 취소 요청을 할 수 있는데 작업 취소 처리는 dart:isolate.Isolate.kill() 함수로 종료 요청할 수 있습니다.
더 이상 데이터 구독이 필요하지 않는 다면 dart:isolate.ReceivePort.close() 로 ReceivePort를 닫아주어야 합니다.

receivePort.close();
Isolate.kill(priority: Isolate.immediate);

이렇게 Flutter에서 dart:isolate.Isolate 를 사용해서 멀티 스레딩 처리를 알아 보았습니다.

위 코드는 다음 Repository에서 확인할 수 있습니다.
Code_check - Flutter Isolate example