한국어
Qt
 

Qt 6 Qt 6의 비동기 API

makersweb 2020.10.19 22:13 조회 수 : 1197

이 글에서는 Qt 6에 도입 된 상위 수준의 비동기 API와 변경 사항에 대해 설명한다.

 

Qt의 상위 수준 병렬 처리 API

Qt Concurrent는 저수준 동기화 (뮤텍스 및 잠금과 같은 기본 요소)의 필요성을 제거하고 여러 스레드를 수동으로 관리함으로써 다중 스레드 프로그래밍을 더 쉽게 만든다.

반복 가능한 컨테이너의 병렬 처리를 위해 맵, 필터 및 축소 알고리즘 (함수 프로그래밍에서 더 잘 알려져 있음)을 제공한다. 또한 QFuture, QFutureWatcher 및 QFutureSynchronizer와 같은 클래스가있어 비동기 계산 결과에 액세스하고 모니터링 할 수 있다.

대체적으로 매우 유용하지만 Qt Concurrent 외부에서 QFuture를 사용할 수 없고, 더 간단하고 깔끔한 코드를 위한 다중 계산 결합에 대한 지원 부족, Qt Concurrent API의 유연성 부족 등과 같은 몇 가지 단점이 있다.

Qt 6의 경우 지난 몇 년 동안 받은 피드백을 고려하여 Qt의 멀티 스레드 프로그래밍을보다 즐겁고 재미있게 만들려고 노력했다.

 

QFuture에 연속 연결

멀티 스레드 프로그래밍의 일반적인 시나리오는 비동기 연산을 수행하는 것이다. 연산은 다시 다른 비동기 명령을 호출하고 다른 비동기 연산에 의존하는 데이터를 전달해야 한다.

각 단계에는 이전 단계의 결과가 필요하므로 이전 단계가 완료 될 때까지 (블로킹 또는 폴링하여) 기다렸다가 결과를 사용하거나 "콜백"스타일로 코드를 구성해야 한다.

이러한 옵션 중 어느 것도 이상적이지 않다. 대기중인 리소스를 낭비하거나 유지관리할 수 없는 복잡한 코드로 끝난다. 새로운 단계 또는 로직 (오류 처리 등)을 추가하면 복잡성이 더욱 증가할 것이다.

문제를 더 잘 이해하기 위해 다음의 예를 들어보자. 웹에서 큰 이미지를 다운로드하고 그에 대해 무거운 처리를 하고 결과 이미지를 애플리케이션에 표시하려고 한다고 가정 해 보겠다. 즉, 다음 단계를 수행해야 한다.

  • 네트워크 요청을 하고 모든 데이터가 수신 될 때까지 대기.
  • 원시 데이터에서 이미지를 생성.
  • 이미지 처리.
  • 이미지를 장치로 출력.

 

순차적으로 호출해야 하는 각 단계에 대해 다음 메서드가 있다.

QByteArray download(const QUrl &url);
QImage createImage(const QByteArray &data);
QImage processImage(const QImage &image);
void show(const QImage &image);

QtConcurrent를 사용하여 이러한 작업을 비동기 적으로 수행하고 QFutureWatcher 를 사용하여 진행 상황을 모니터링 할 수 있다.

void loadImage(const QUrl &url) {
    QFuture data = QtConcurrent::run(download, url);
    QFutureWatcher dataWatcher;
    dataWatcher.setFuture(data);
    
    connect(&dataWatcher, &QFutureWatcher ::finished, this, [=] {
        // handle possible errors
        // ...
        QImage image = createImage(data);
        // Process the image
        // ...
        QFuture processedImage = QtConcurrent::run(processImage, image);
        QFutureWatcher<QImage> imageWatcher;
        imageWatcher.setFuture(processedImage);

        connect(&imageWatcher, &QFutureWatcher::finished, this, [=] {
            // handle possible errors
            // ...
            show(processedImage);
        });
    });
}

좋아보이는가? 응용 프로그램 로직은 프로그램 구성 요소를 함께 묶는 데 필요한 표준 코드와 혼합된다. 그리고 더 많은 단계를 추가할수록 더 악화된다는 것을 알고 있다.

QFuture는 QFuture::then() 메서드를 통해 연속 연결 지원을 지원하여 이 문제를 해결하는 데 도움이 된다.

auto future = QtConcurrent::run(download, url)
            .then(createImage)
            .then(processImage)
            .then(show);

확실히 훨씬 좋아 보인다! 그러나 에러처리가 누락된 것을 알 수있다. 다음과 같이 할 수 있다.

auto future = QtConcurrent::run(download, url)
            .then([](QByteArray data) {
                // handle possible errors from the previous step
                // ...
                return createImage(data);
            })    
            .then(...)    
            ...

이 코드는 작동하지만 에러 처리 코드는 여전히 프로그램 로직과 혼합되어 있다. 또한 단계 중 하나가 실패하면 전체 체인을 실행할 이유가 없을 것이다. 이 문제는 QFuture::onFailed() 메서드로 해결할 수 있으며, 이를 통해 각 에러 유형에 대해 특정 에러 처리기를 연결할 수 있다.

auto future = QtConcurrent::run(download, url)
            .then(createImage)
            .then(processImage)
            .then(show)
            .onFailed([](QNetworkReply::NetworkError) {
                // handle network errors
            })
            .onFailed([](ImageProcessingError) {
                // handle image processing errors
            })
            .onFailed([] {
                // handle any other error
            });

.onFailed()를 사용하려면 예외를 활성화해야한다. 예외로 인해 단계가 실패하면 체인이 끊어지고 throw 된 예외 유형과 일치하는 에러 처리기가 호출된다.

.then() 및 onFailed()와 마찬가지로 Future 가 취소될 것을 대비하여 핸들러를 연결하는 .onCanceled()도 있다.

 

시그널에서 QFuture 생성

시그널 또한 미래에 언젠가 사용할 수 있는 무언가를 나타내기 때문에 future와 같이 작업하고 연속, 실패 처리기 등을 첨부 할 수 있는 것이 자연스러워 보인다. void mySignal(int) 시그널이 있는 QObject 기반 클래스 MyObject가 주어지면 이 시그널을 다음과 같은 방법으로 future로 사용할 수 있다.

QFuture intFuture = QtFuture::connect(&object, &MyObject::mySignal);

이제 결과 future에 연속, 실패 또는 취소 처리기를 연결할 수 있다.

결과 미래의 유형은 시그널의 인수 유형과 일치한다. 인수가 없으면 QFuture<void>가 반환되며 인수가 여러 개인 경우 결과는 std::tuple에 저장된다.

이미지 처리 예제의 첫 번째 (즉, 다운로드) 단계로 돌아가서 이것이 실제로 어떻게 유용 할 수 있는지 살펴 보자. 이를 구현하는 방법에는 여러 가지가 있다. QNetworkAccessManager 를 사용하여 네트워크 요청을 보내고 데이터를 가져온다.

QNetworkAccessManager manager;    
...

QByteArray download(const QUrl &url) {        
    QNetworkReply *reply = manager.get(QNetworkRequest(url));
    QObject::connect(reply, &QNetworkReply::finished, [reply] {...});
    
    // wait until we've received all data
    // ...    
    return data;        
}

그러나 잠금을 기다리는 것은 좋지 않다. 이를 제거하고 대신 "QNetworkAccessManager가 데이터를 수신하면 이미지를 생성 한 다음 처리하고 표시"라고 표현하면 더 좋을 것이다. 네트워크 액세스 관리자의 finished() 시그널을 QFuture에 연결하여 이를 수행 할 수 있다.

QNetworkReply *reply = manager.get(QNetworkRequest(url));

auto future = QtFuture::connect(reply, &QNetworkReply::finished)
        .then([reply] {
            return reply->readAll();
        })
        .then(QtFuture::Launch::Async, createImage)
        .then(processImage)
        .then(show)        
        ...

이제 QtConcurrent::run()을 사용하여 데이터를 비동기적으로 다운로드하고 새 스레드에 반환하는 대신, 단순히 QNetworkAccessManager::finished() 시그널에 연결하여 연산 체인을 시작한다는 것을 알 수 있다. 또한 다음 행의 추가 매개 변수에 주목하자.

        .then(QtFuture::Launch::Async, createImage)

기본적으로 .then()에 의해 첨부된 연속성은 부모가 실행해 온 동일한 스레드(우리의 경우 메인 스레드)에서 호출된다. QtConcurrent::run()을 사용하여 체인을 비동기 적으로 시작하지 않았으므로 추가 매개 변수(QtFuture::Launch::Async)를 전달하여 별도의 스레드에서 연속 체인을 시작하고 UI 차단을 방지해야 한다.