중요

번역은 여러분이 참여할 수 있는 커뮤니티 활동입니다. 이 페이지는 현재 100.00% 번역되었습니다.

15. 태스크 - 배경에서 무거운 작업 하기

힌트

PyQGIS 콘솔을 사용하지 않는 경우 이 페이지에 있는 코드 조각들을 다음과 같이 가져와야 합니다:

 1from qgis.core import (
 2  Qgis,
 3  QgsApplication,
 4  QgsMessageLog,
 5  QgsProcessingAlgRunnerTask,
 6  QgsProcessingContext,
 7  QgsProcessingFeedback,
 8  QgsProject,
 9  QgsTask,
10  QgsTaskManager,
11)

15.1. 소개

무거운 공간 처리 작업이 진행 중일 때, 스레드들을 사용한 배경 프로세스가 즉각 반응하는 사용자 인터페이스를 유지하는 한 방법일 수 있습니다. QGIS에서는 스레드 작업을 태스크(task)를 사용해서 달성할 수 있습니다.

태스크(QgsTask 클래스)란 배경에서 수행될 코드를 위한 컨테이너이며, 태스크 관리자(QgsTaskManager 클래스)는 태스크의 실행을 제어하는 데 쓰입니다. 이 클래스들은 신호 전달, 진행률 보고 및 배경 프로세스의 상태에 접근하기 위한 메커니즘을 제공해서 QGIS에서의 배경 프로세스 작업을 단순화시켜줍니다. 태스크는 하위 태스크를 사용해서 그룹화할 수 있습니다.

일반적으로 (QgsApplication.taskManager() 메소드로 호출할 수 있는) 전체 수준 태스크 관리자를 사용합니다. 다시 말해 태스크 관리자가 여러분의 태스크만 제어하는 것이 아닐 수도 있다는 뜻입니다.

몇 가지 방법으로 QGIS 태스크를 생성할 수 있습니다:

  • QgsTask 클래스를 확장시켜 사용자의 태스크를 생성합니다:

    class SpecialisedTask(QgsTask):
        pass
    
  • 함수로부터 태스크를 생성합니다:

     1def heavyFunction():
     2    # Some CPU intensive processing ...
     3    pass
     4
     5def workdone():
     6    # ... do something useful with the results
     7    pass
     8
     9task = QgsTask.fromFunction('heavy function', heavyFunction,
    10                     on_finished=workdone)
    
  • 공간 처리 알고리즘으로부터 태스크를 생성합니다:

    1params = dict()
    2context = QgsProcessingContext()
    3context.setProject(QgsProject.instance())
    4feedback = QgsProcessingFeedback()
    5
    6buffer_alg = QgsApplication.instance().processingRegistry().algorithmById('native:buffer')
    7task = QgsProcessingAlgRunnerTask(buffer_alg, params, context,
    8                           feedback)
    

경고

어떤 배경 태스크도 (생성 방법에 상관없이) 주 스레드에 살아 있는, 예를 들면 QgsVectorLayer, QgsProject에 접근하거나 또는 새 위젯을 생성하거나 기존 위젯과 쌍방향 작업을 하는 것과 같은 GUI 기반 작업을 수행하는 QObject를 절대로 사용해서는 안 됩니다. Qt 위젯은 주 스레드에서만 접근 또는 수정해야만 합니다. 태스크에 쓰이는 데이터는 태스크를 시작하기 전에 반드시 복사해야만 합니다. 배경 스레드에서 이들을 사용하려 시도하는 경우 충돌이 발생할 것입니다.

또한 contextfeedback 클래스들이 적어도 이들을 사용하는 태스크만큼은 살아 있도록 항상 확인하십시오. 태스크 완료 시 QgsTaskManager 가 태스크가 예약되었던 contextfeedback 에 접근하지 못하는 경우 QGIS가 중단될 것입니다.

참고

QgsProcessingContext 클래스를 호출한 직후 setProject() 메소드를 호출하는 것이 흔한 패턴입니다. 이렇게 하면 태스크는 물론 태스크의 콜백 함수가 프로젝트 전체 설정 가운데 대부분을 사용할 수 있습니다. 콜백 함수에서 공간 레이어를 작업하는 경우 특히 유용합니다.

QgsTask 클래스의 addSubTask() 함수를 사용하면 태스크들 사이의 의존성을 설명할 수 있습니다. 의존성을 선언하는 경우, 태스크 관리자가 이런 의존성들을 실행하는 방법을 자동으로 결정할 것입니다. 의존성들을 가능한 한 빨리 만족시키기 위해 의존성들을 병렬로 실행할 것입니다. 또다른 태스크가 의존하고 있는 태스크를 취소하는 경우, 의존하는 태스크도 취소될 것입니다. 순환 의존성은 교착 상태(deadlock)를 발생시킬 수 있기 때문에 조심해야 합니다.

태스크가 사용할 수 있는 레이어에 의존하는 경우, QgsTask 클래스의 setDependentLayers() 함수를 사용해서 이를 선언할 수 있습니다. 태스크가 의존하는 레이어를 사용할 수 없는 경우 태스크가 취소될 것입니다.

태스크를 생성하고 나면 태스크 관리자의 addTask() 함수를 사용해서 실행을 예약할 수 있습니다. 관리자에 태스크를 추가하면 자동적으로 관리자가 해당 태스크를 소유하게 되며, 태스크를 실행한 후 관리자가 태스크를 정리하고 삭제할 것입니다. 태스크의 예약은 태스크 우선 순위에 영향을 받는데, addTask() 메소드에서 이 우선 순위를 설정합니다.

QgsTaskQgsTaskManager 클래스의 신호와 함수를 통해 태스크의 상태를 모니터링할 수 있습니다.

15.2. 예제

15.2.1. QgsTask 클래스 확장하기

다음 예시에서는 RandomIntegerSumTaskQgsTask 클래스를 확장시키고 지정한 시간 동안 0에서 500 사이의 정수 100개를 랜덤하게 생성할 것입니다. 이때 랜덤한 숫자가 42일 경우, 태스크를 중단하고 예외를 발생시킵니다. 두 가지 유형의 의존성을 보여주는 (하위 태스크를 가진) RandomIntegerSumTask 의 인스턴스를 몇 개 생성해서 태스크 관리자에 추가합니다.

  1import random
  2from time import sleep
  3
  4from qgis.core import (
  5    QgsApplication, QgsTask, QgsMessageLog, Qgis
  6    )
  7
  8MESSAGE_CATEGORY = 'RandomIntegerSumTask'
  9
 10class RandomIntegerSumTask(QgsTask):
 11    """This shows how to subclass QgsTask"""
 12
 13    def __init__(self, description, duration):
 14        super().__init__(description, QgsTask.CanCancel)
 15        self.duration = duration
 16        self.total = 0
 17        self.iterations = 0
 18        self.exception = None
 19
 20    def run(self):
 21        """Here you implement your heavy lifting.
 22        Should periodically test for isCanceled() to gracefully
 23        abort.
 24        This method MUST return True or False.
 25        Raising exceptions will crash QGIS, so we handle them
 26        internally and raise them in self.finished
 27        """
 28        QgsMessageLog.logMessage('Started task "{}"'.format(
 29                                     self.description()),
 30                                 MESSAGE_CATEGORY, Qgis.Info)
 31        wait_time = self.duration / 100
 32        for i in range(100):
 33            sleep(wait_time)
 34            # use setProgress to report progress
 35            self.setProgress(i)
 36            arandominteger = random.randint(0, 500)
 37            self.total += arandominteger
 38            self.iterations += 1
 39            # check isCanceled() to handle cancellation
 40            if self.isCanceled():
 41                return False
 42            # simulate exceptions to show how to abort task
 43            if arandominteger == 42:
 44                # DO NOT raise Exception('bad value!')
 45                # this would crash QGIS
 46                self.exception = Exception('bad value!')
 47                return False
 48        return True
 49
 50    def finished(self, result):
 51        """
 52        This function is automatically called when the task has
 53        completed (successfully or not).
 54        You implement finished() to do whatever follow-up stuff
 55        should happen after the task is complete.
 56        finished is always called from the main thread, so it's safe
 57        to do GUI operations and raise Python exceptions here.
 58        result is the return value from self.run.
 59        """
 60        if result:
 61            QgsMessageLog.logMessage(
 62                'RandomTask "{name}" completed\n' \
 63                'RandomTotal: {total} (with {iterations} '\
 64              'iterations)'.format(
 65                  name=self.description(),
 66                  total=self.total,
 67                  iterations=self.iterations),
 68              MESSAGE_CATEGORY, Qgis.Success)
 69        else:
 70            if self.exception is None:
 71                QgsMessageLog.logMessage(
 72                    'RandomTask "{name}" not successful but without '\
 73                    'exception (probably the task was manually '\
 74                    'canceled by the user)'.format(
 75                        name=self.description()),
 76                    MESSAGE_CATEGORY, Qgis.Warning)
 77            else:
 78                QgsMessageLog.logMessage(
 79                    'RandomTask "{name}" Exception: {exception}'.format(
 80                        name=self.description(),
 81                        exception=self.exception),
 82                    MESSAGE_CATEGORY, Qgis.Critical)
 83                raise self.exception
 84
 85    def cancel(self):
 86        QgsMessageLog.logMessage(
 87            'RandomTask "{name}" was canceled'.format(
 88                name=self.description()),
 89            MESSAGE_CATEGORY, Qgis.Info)
 90        super().cancel()
 91
 92
 93longtask = RandomIntegerSumTask('waste cpu long', 20)
 94shorttask = RandomIntegerSumTask('waste cpu short', 10)
 95minitask = RandomIntegerSumTask('waste cpu mini', 5)
 96shortsubtask = RandomIntegerSumTask('waste cpu subtask short', 5)
 97longsubtask = RandomIntegerSumTask('waste cpu subtask long', 10)
 98shortestsubtask = RandomIntegerSumTask('waste cpu subtask shortest', 4)
 99
100# Add a subtask (shortsubtask) to shorttask that must run after
101# minitask and longtask has finished
102shorttask.addSubTask(shortsubtask, [minitask, longtask])
103# Add a subtask (longsubtask) to longtask that must be run
104# before the parent task
105longtask.addSubTask(longsubtask, [], QgsTask.ParentDependsOnSubTask)
106# Add a subtask (shortestsubtask) to longtask
107longtask.addSubTask(shortestsubtask)
108
109QgsApplication.taskManager().addTask(longtask)
110QgsApplication.taskManager().addTask(shorttask)
111QgsApplication.taskManager().addTask(minitask)
 1RandomIntegerSumTask(0): Started task "waste cpu subtask shortest"
 2RandomIntegerSumTask(0): Started task "waste cpu short"
 3RandomIntegerSumTask(0): Started task "waste cpu mini"
 4RandomIntegerSumTask(0): Started task "waste cpu subtask long"
 5RandomIntegerSumTask(3): Task "waste cpu subtask shortest" completed
 6RandomTotal: 25452 (with 100 iterations)
 7RandomIntegerSumTask(3): Task "waste cpu mini" completed
 8RandomTotal: 23810 (with 100 iterations)
 9RandomIntegerSumTask(3): Task "waste cpu subtask long" completed
10RandomTotal: 26308 (with 100 iterations)
11RandomIntegerSumTask(0): Started task "waste cpu long"
12RandomIntegerSumTask(3): Task "waste cpu long" completed
13RandomTotal: 22534 (with 100 iterations)

15.2.2. 함수로부터 나온 태스크

함수로부터 (이 예시에서는 doSomething 으로부터) 태스크를 생성합니다. 이 함수의 첫 번째 파라미터가 함수를 위한 QgsTask 클래스를 담을 것입니다. on_finished 는 태스크 완료 시 호출할 함수를 지정하는 중요한 (이름이 있는) 파라미터입니다. 이 예시의 doSomething 함수는 추가적인 이름이 있는 파라미터 wait_time 을 가지고 있습니다.

 1import random
 2from time import sleep
 3
 4MESSAGE_CATEGORY = 'TaskFromFunction'
 5
 6def doSomething(task, wait_time):
 7    """
 8    Raises an exception to abort the task.
 9    Returns a result if success.
10    The result will be passed, together with the exception (None in
11    the case of success), to the on_finished method.
12    If there is an exception, there will be no result.
13    """
14    QgsMessageLog.logMessage('Started task {}'.format(task.description()),
15                             MESSAGE_CATEGORY, Qgis.Info)
16    wait_time = wait_time / 100
17    total = 0
18    iterations = 0
19    for i in range(100):
20        sleep(wait_time)
21        # use task.setProgress to report progress
22        task.setProgress(i)
23        arandominteger = random.randint(0, 500)
24        total += arandominteger
25        iterations += 1
26        # check task.isCanceled() to handle cancellation
27        if task.isCanceled():
28            stopped(task)
29            return None
30        # raise an exception to abort the task
31        if arandominteger == 42:
32            raise Exception('bad value!')
33    return {'total': total, 'iterations': iterations,
34            'task': task.description()}
35
36def stopped(task):
37    QgsMessageLog.logMessage(
38        'Task "{name}" was canceled'.format(
39            name=task.description()),
40        MESSAGE_CATEGORY, Qgis.Info)
41
42def completed(exception, result=None):
43    """This is called when doSomething is finished.
44    Exception is not None if doSomething raises an exception.
45    result is the return value of doSomething."""
46    if exception is None:
47        if result is None:
48            QgsMessageLog.logMessage(
49                'Completed with no exception and no result '\
50                '(probably manually canceled by the user)',
51                MESSAGE_CATEGORY, Qgis.Warning)
52        else:
53            QgsMessageLog.logMessage(
54                'Task {name} completed\n'
55                'Total: {total} ( with {iterations} '
56                'iterations)'.format(
57                    name=result['task'],
58                    total=result['total'],
59                    iterations=result['iterations']),
60                MESSAGE_CATEGORY, Qgis.Info)
61    else:
62        QgsMessageLog.logMessage("Exception: {}".format(exception),
63                                 MESSAGE_CATEGORY, Qgis.Critical)
64        raise exception
65
66# Create a few tasks
67task1 = QgsTask.fromFunction('Waste cpu 1', doSomething,
68                             on_finished=completed, wait_time=4)
69task2 = QgsTask.fromFunction('Waste cpu 2', doSomething,
70                             on_finished=completed, wait_time=3)
71QgsApplication.taskManager().addTask(task1)
72QgsApplication.taskManager().addTask(task2)
1RandomIntegerSumTask(0): Started task "waste cpu subtask short"
2RandomTaskFromFunction(0): Started task Waste cpu 1
3RandomTaskFromFunction(0): Started task Waste cpu 2
4RandomTaskFromFunction(0): Task Waste cpu 2 completed
5RandomTotal: 23263 ( with 100 iterations)
6RandomTaskFromFunction(0): Task Waste cpu 1 completed
7RandomTotal: 25044 ( with 100 iterations)

15.2.3. 공간 처리 알고리즘으로부터 나온 태스크

지정한 범위 안에 포인트 50,000개를 qgis:randompointsinextent 알고리즘을 사용해서 랜덤하게 생성하는 태스크를 생성합니다. 결과물은 프로젝트에 안전한 방식으로 추가됩니다.

 1from functools import partial
 2from qgis.core import (QgsTaskManager, QgsMessageLog,
 3                       QgsProcessingAlgRunnerTask, QgsApplication,
 4                       QgsProcessingContext, QgsProcessingFeedback,
 5                       QgsProject)
 6
 7MESSAGE_CATEGORY = 'AlgRunnerTask'
 8
 9def task_finished(context, successful, results):
10    if not successful:
11        QgsMessageLog.logMessage('Task finished unsucessfully',
12                                 MESSAGE_CATEGORY, Qgis.Warning)
13    output_layer = context.getMapLayer(results['OUTPUT'])
14    # because getMapLayer doesn't transfer ownership, the layer will
15    # be deleted when context goes out of scope and you'll get a
16    # crash.
17    # takeMapLayer transfers ownership so it's then safe to add it
18    # to the project and give the project ownership.
19    if output_layer and output_layer.isValid():
20        QgsProject.instance().addMapLayer(
21             context.takeResultLayer(output_layer.id()))
22
23alg = QgsApplication.processingRegistry().algorithmById(
24                                      'qgis:randompointsinextent')
25# `context` and `feedback` need to
26# live for as least as long as `task`,
27# otherwise the program will crash.
28# Initializing them globally is a sure way
29# of avoiding this unfortunate situation.
30context = QgsProcessingContext()
31feedback = QgsProcessingFeedback()
32params = {
33    'EXTENT': '0.0,10.0,40,50 [EPSG:4326]',
34    'MIN_DISTANCE': 0.0,
35    'POINTS_NUMBER': 50000,
36    'TARGET_CRS': 'EPSG:4326',
37    'OUTPUT': 'memory:My random points'
38}
39task = QgsProcessingAlgRunnerTask(alg, params, context, feedback)
40task.executed.connect(partial(task_finished, context))
41QgsApplication.taskManager().addTask(task)

https://www.opengis.ch/2018/06/22/threads-in-pyqgis3/ 도 참조하세요.