openlang

필터를 async로 바꿔도 화면이 멈춘다

검색할 때마다 행 4만 개쯤 되는 목록을 필터링하는데, 그때마다 UI가 잠깐 멈춰요. async로 바꾸면 되겠지 싶어서 필터를 이렇게 만들고 탭 핸들러에서 await했어요.

Future<List<Entry>> filterRows(List<Entry> rows, String query) async {
  final out = <Entry>[];
  for (final r in rows) {
    if (r.matches(query)) out.add(r);
  }
  return out;
}

// in the tap handler
setState(() => loading = true);
final result = await filterRows(allRows, query);

똑같이 멈춰요. 호출 직전에 켜는 스피너조차 안 나타나요. Isolate.run이 있다는 건 알아요. 큰 JSON 디코딩에 써 본 적도 있고요. 그런데 필터 하나에 쓰기엔 거창해 보였고, await가 기다리는 동안에도 프레임은 계속 돌게 해 주는 줄 알았어요. 여기서 await는 대체 뭘 하고 있는 건가요?

영어에서 번역원문 보기

이어지는 대화 8

서로의 근거를 따라
  1. 고유 링크

    저는 이걸 Java의 CompletableFuture.supplyAsync 호출 읽듯이 읽었어요. 거기서는 본문이 풀 스레드에서 돌고 호출한 쪽은 값만 기다리니까, 안에서 느린 루프가 돌아도 UI 스레드는 건드리지 않아요. 여기 Future도 같은 식이라면 멈추는 원인은 필터 자체가 아니에요. 그보다는 await 뒤에 도는 코드를 보겠어요. 행 4만 개를 UI 스레드에서 한 번에 다시 만드는 데 그 정도는 충분히 걸릴 수 있고, 스피너는 그 리빌드와의 경쟁에서 지고 있는 걸지도 몰라요.

    영어에서 번역원문 보기
  2. 고유 링크

    아니에요. Java에서 오면 그렇게 연결하기 쉬워요. Dart의 Future는 나중에 도착하는 값이지 스레드가 아니에요. 앱 전체가 isolate 하나에서 돌고, 그 isolate에는 이벤트 루프 하나를 돌리는 스레드가 하나 있어요. 게다가 async 함수는 첫 await까지 동기로 실행돼요. filterRows 안에는 await가 없으니까 루프 전체가 탭 핸들러 그 자리에서, Future가 반환되기도 전에 끝까지 돌아요. 스피너를 그릴 프레임은 그 뒤에서 이벤트 큐에 앉아 기다리고요. await 뒤의 리빌드는 느린 부분이 아니에요. 루프가 끝날 때까지 시작조차 못 하거든요.

    그 루프에서 일을 빼려면 isolate가 하나 더 있어야 해요.

    import 'dart:isolate';
    
    List<Entry> filterRows(List<Entry> rows, String query) =>
        rows.where((r) => r.matches(query)).toList();
    
    // in the tap handler
    final result = await Isolate.run(() => filterRows(rows, query));

    이제 await는 다른 isolate가 보내는 메시지를 기다리고, 그동안 이벤트 루프는 비어 있어요. 키워드가 일을 어딘가 다른 데로 옮겨 줄 것처럼 덜 들렸으면 좋겠어요.

    영어에서 번역원문 보기
  3. 고유 링크

    맞아요. 제가 거꾸로 알고 있었어요. supplyAsync의 풀 스레드를 그대로 가져왔는데, 그게 바로 Dart가 약속한 적 없는 부분이었네요. 그걸 바로잡고 n.vos 님 코드를 다시 읽으니 루프는 호출 안에서 그대로 돌고, 제가 탓했던 리빌드는 화면이 멈췄을 때 시작도 안 한 상태였어요.

    영어에서 번역원문 보기
  4. 고유 링크

    그러면 async 표시만으로는 아무것도 옮겨지지 않는 셈입니다. 루프가 천 개 행마다 무언가를 await하면 프레임이 끼어들 수 있습니까, 아니면 무엇을 await하느냐에 달려 있습니까?

    영어에서 번역원문 보기
  5. 고유 링크

    무엇을 await하느냐에 달려 있어요. 프레임이 큐를 어떻게 지나가는지 따라가 보면 돼요. 이미 완료된 future에 await하면 마이크로태스크에서 재개돼요. 마이크로태스크는 다음 이벤트보다 전부 먼저 돌아요. 프레임은 이벤트라서 여전히 루프 전체 뒤에서 기다려요. await Future.delayed(Duration.zero)는 타이머에서 재개되고, 타이머는 이벤트예요. 이제 루프가 이벤트 큐 맨 뒤로 가고, 먼저 기다리던 프레임이 차례를 받아요. 그래서 나눠 도는 버전은 화면을 계속 그려요. 다만 각 덩어리는 여전히 UI isolate에서 돌고, 덩어리가 너무 길면 똑같이 프레임이 빠져요.

    영어에서 번역원문 보기
  6. 고유 링크

    됐어요, 한 번 걸려 넘어지긴 했지만요. 제 핸들러에서 allRows는 필드라서, 처음엔 클로저 안에서 그걸 바로 썼어요. 그러면 this가 캡처되고 State 전체가 isolate로 딸려 가요. 제 Loader 때랑 똑같은 함정이에요. Isolate.run 전에 로컬 변수로 복사하니까 해결됐고, 이제 필터가 도는 동안 스피너도 보이고 목록도 스크롤돼요.

    영어에서 번역원문 보기
  7. 고유 링크

    동기로 시작하는 동작은 보기보다 새것입니다. 초기 Dart는 반대였고, async 함수는 Future를 먼저 돌려주고 본문은 나중에야 시작했습니다. Dart 2가 이를 바꿔 본문이 첫 await까지 동기로 실행되게 했습니다(호환성을 깨는 변경으로 분류됐는데 그럴 만했습니다. 아무도 적어 두지 않은 채 본문이 나중에 시작된다는 점에 기대던 코드가 있었고, 새 규칙은 JavaScript async 함수가 처음부터 해 오던 방식과 같았기 때문입니다). 어느 규칙에서든 n.vos 님의 루프는 하나뿐인 isolate에서 도니, 화면은 처음부터 멈췄을 겁니다.

    영어에서 번역원문 보기
  8. 고유 링크

    여기서는 Isolate.run이 맞는 기본값이에요. 다만 한 가지는 좁혀 둘게요. 클로저가 캡처한 건 전부 새 isolate로 보내지니까, 검색할 때마다 행 4만 개가 복사본으로 건너가요. 결과는 Isolate.exit으로 돌아오는데 문서에 따르면 복사 없이 보내지니까, 비용은 입력 쪽만 내요. 그 비용은 목록과 함께 커지니까, 멈춤이 해결됐다고 하기 전에 실제 데이터로 검색을 한 번 재 보는 게 좋겠어요.

    영어에서 번역원문 보기

같은 질문의 다른 갈래

  1. fetch 하나가 실패할 때의 asyncio.TaskGroup

    언어끼리 동시성을 비교할 때는 보통 문법부터 봐요. 저는 뭔가 실패했을 때 계산에 무엇이 필요한지부터 보고 싶어요. 작은 Python 애플리케이션이 측정값과 가중치를 동시에 가져와서 점수 하나로 합쳐요. 그런데 fetch 하…

  2. swap! 함수 안의 println

    swap! 문서에는 함수가 여러 번 호출될 수 있으니 부수 효과가 없어야 한다고 적혀 있다. 그런데 실제 코드에는 그 안에서 로그를 찍는 경우가 여전히 있다. 아톰의 값은 어떤 경우에도 맞다. 커밋되는 시도는 하나뿐이기 때문…

  3. Isolate.run이 Loader를 통째로 데려갔어요

    큰 JSON 디코딩을 Isolate.run으로 메인 isolate 밖에 빼 봤는데, 빨라지기는커녕 보낼 수 없는 객체라는 오류가 났어요. 디코딩 자체는 문제가 없었고 클로저가 문제였어요. _decode가 인스턴스 메서드라서…

    답글 2dart