在Python的调用者线程中捕获线程的异常


207

我对Python和多线程编程非常陌生。基本上,我有一个脚本可以将文件复制到另一个位置。我希望将其放置在另一个线程中,以便可以输出....以指示脚本仍在运行。

我遇到的问题是,如果无法复制文件,它将引发异常。如果在主线程中运行,这没关系;但是,具有以下代码不起作用:

try:
    threadClass = TheThread(param1, param2, etc.)
    threadClass.start()   ##### **Exception takes place here**
except:
    print "Caught an exception"

在线程类本身中,我试图重新引发异常,但是它不起作用。我已经看到这里的人问类似的问题,但是他们似乎都在做比我想做的事情更具体的事情(而且我不太了解所提供的解决方案)。我见过有人提到的用法sys.exc_info(),但是我不知道在哪里或如何使用它。

非常感谢所有帮助!

编辑:线程类的代码如下:

class TheThread(threading.Thread):
    def __init__(self, sourceFolder, destFolder):
        threading.Thread.__init__(self)
        self.sourceFolder = sourceFolder
        self.destFolder = destFolder

    def run(self):
        try:
           shul.copytree(self.sourceFolder, self.destFolder)
        except:
           raise

您能否提供更多有关内部情况的见解TheThread?代码示例?
贾坦教徒

当然。我将在上面编辑我的回复以包含一些详细信息。
潘多

1
您是否考虑过将其切换为圆形,以便主线程是填充位,进度指示器位于生成的线程中?
丹·海德

1
丹·海德(Dan Head),您是指主线程首先生成“ ...”函数,然后运行复制函数吗?这样可以正常工作,避免出现异常问题。但是,我仍然想学习如何正确地在python中进行线程化。
潘多

Answers:


114

问题是thread_obj.start()立即返回。您产生的子线程在其自己的上下文中执行,并带有自己的堆栈。在那里发生的任何异常都在子线程的上下文中,并且在其自己的堆栈中。我现在可以想到的一种将此信息传达给父线程的方法是使用某种消息传递,因此您可能会对此进行研究。

尝试以下尺寸:

import sys
import threading
import Queue


class ExcThread(threading.Thread):

    def __init__(self, bucket):
        threading.Thread.__init__(self)
        self.bucket = bucket

    def run(self):
        try:
            raise Exception('An error occured here.')
        except Exception:
            self.bucket.put(sys.exc_info())


def main():
    bucket = Queue.Queue()
    thread_obj = ExcThread(bucket)
    thread_obj.start()

    while True:
        try:
            exc = bucket.get(block=False)
        except Queue.Empty:
            pass
        else:
            exc_type, exc_obj, exc_trace = exc
            # deal with the exception
            print exc_type, exc_obj
            print exc_trace

        thread_obj.join(0.1)
        if thread_obj.isAlive():
            continue
        else:
            break


if __name__ == '__main__':
    main()

5
为什么不加入线程而不是这个丑陋的while循环?参见multiprocessing等效文件:gist.github.com/2311116
schlamar 2012年

1
为什么不基于@Lasse答案使用EventHook模式stackoverflow.com/questions/1092531/event-system-in-python/…?而不是循环的东西?
2014年

1
队列不是传回错误的最佳工具,除非您希望拥有完整的队列。更好的构造是threading.Event()
Muposat's

1
这对我来说似乎是不安全的。当线程在引发之后立即bucket.get()引发异常时会发生什么Queue.Empty?然后,该线程join(0.1)将完成和isAlive() is False,并且您错过了异常。
史蒂夫

1
Queue在这种简单情况下是没有必要的-您只要将异常信息存储为的属性即可,ExcThread只要确保run()在异常之后立即完成即可(在此简单示例中就是如此)。然后,您只需在(之后)(或期间)重新引发异常t.join()。因为join()确保线程已完成,所以没有同步问题。请参阅stackoverflow.com/a/12223550/126362下的
RokStrniša的答复-ejm

42

通过该concurrent.futures模块,可以轻松地在单独的线程(或进程)中进行工作并处理任何导致的异常:

import concurrent.futures
import shutil

def copytree_with_dots(src_path, dst_path):
    with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
        # Execute the copy on a separate thread,
        # creating a future object to track progress.
        future = executor.submit(shutil.copytree, src_path, dst_path)

        while future.running():
            # Print pretty dots here.
            pass

        # Return the value returned by shutil.copytree(), None.
        # Raise any exceptions raised during the copy process.
        return future.result()

concurrent.futures包含在Python 3.2中,并且可以作为早期版本的向后移植futures模块


5
尽管这并不能完全满足OP的要求,但这恰恰是我需要的提示。谢谢。
疯狂物理学家

2
并用concurrent.futures.as_completed,你可以得到立即通知作为例外提出:stackoverflow.com/questions/2829329/...
西罗桑蒂利郝海东冠状病六四事件法轮功

1
该代码阻塞了主线程。您如何异步执行此操作?
Nikolay Shindarov,

40

这个问题有很多非常复杂的答案。我是否对此简化了,因为这对我来说大多数事情似乎已经足够。

from threading import Thread

class PropagatingThread(Thread):
    def run(self):
        self.exc = None
        try:
            if hasattr(self, '_Thread__target'):
                # Thread uses name mangling prior to Python 3.
                self.ret = self._Thread__target(*self._Thread__args, **self._Thread__kwargs)
            else:
                self.ret = self._target(*self._args, **self._kwargs)
        except BaseException as e:
            self.exc = e

    def join(self):
        super(PropagatingThread, self).join()
        if self.exc:
            raise self.exc
        return self.ret

如果确定只能在一个或另一个版本的Python上运行,则可以将该run()方法缩减为仅损坏的版本(如果仅在3之前的Python版本上运行),或者只是干净的版本(如果您只能在以3开头的Python版本上运行)。

用法示例:

def f(*args, **kwargs):
    print(args)
    print(kwargs)
    raise Exception('I suck at this')

t = PropagatingThread(target=f, args=(5,), kwargs={'hello':'world'})
t.start()
t.join()

当您加入时,您将看到另一个线程引发的异常。

如果仅使用sixPython 3或仅在Python 3上使用,则可以改善重新引发异常时所获得的堆栈跟踪信息。您可以将内部异常包装在新的外部异常中,而不是仅在连接时使用堆栈,并使用

six.raise_from(RuntimeError('Exception in thread'),self.exc)

要么

raise RuntimeError('Exception in thread') from self.exc

1
我不确定为什么这个答案也不受欢迎。这里还有其他一些也可以进行简单的传播,但是需要扩展一个类并进行覆盖。这恰好满足了许多人的期望,并且只需要从Thread更改为ProagatingThread。还有4个空格选项卡,所以我的复制/粘贴操作很简单:-) ...我建议的唯一改进是使用six.raise_from(),这样您就可以得到一个很好的嵌套堆栈跟踪集,而不仅仅是堆栈募捐的地点。
aggieNick02 '19

非常感谢你。非常简单的解决方案。
sonulohani

我的问题是我有多个子线程。联接是按顺序执行的,并且以后的联接线程可能会引发异常。有没有解决我的问题的简单方法?同时运行联接?

谢谢,这很完美!不知道为什么它不能直接由python处理……
GG。

这是定义最有用的答案,这种解决方案比其他解决方案更通用,但也很简单。将在项目中使用它!
Konstantin Sekeresh

30

尽管不可能直接捕获在不同线程中引发的异常,但是这里的代码可以透明地获取与该功能非常接近的内容。在等待线程完成其工作时,您的子线程必须ExThread代替该类的子类,threading.Thread并且父线程必须调用该child_thread.join_with_exception()方法,而不是调用该方法child_thread.join()

此实现的技术细节:当子线程引发异常时,它将通过a传递给Queue父线程,然后再次在父线程中引发。请注意,这种方法无需等待。

#!/usr/bin/env python

import sys
import threading
import Queue

class ExThread(threading.Thread):
    def __init__(self):
        threading.Thread.__init__(self)
        self.__status_queue = Queue.Queue()

    def run_with_exception(self):
        """This method should be overriden."""
        raise NotImplementedError

    def run(self):
        """This method should NOT be overriden."""
        try:
            self.run_with_exception()
        except BaseException:
            self.__status_queue.put(sys.exc_info())
        self.__status_queue.put(None)

    def wait_for_exc_info(self):
        return self.__status_queue.get()

    def join_with_exception(self):
        ex_info = self.wait_for_exc_info()
        if ex_info is None:
            return
        else:
            raise ex_info[1]

class MyException(Exception):
    pass

class MyThread(ExThread):
    def __init__(self):
        ExThread.__init__(self)

    def run_with_exception(self):
        thread_name = threading.current_thread().name
        raise MyException("An error in thread '{}'.".format(thread_name))

def main():
    t = MyThread()
    t.start()
    try:
        t.join_with_exception()
    except MyException as ex:
        thread_name = threading.current_thread().name
        print "Caught a MyException in thread '{}': {}".format(thread_name, ex)

if __name__ == '__main__':
    main()

1
你不是想赶上BaseException,不是Exception吗?您要做的就是将异常传播Thread到另一个之中。现在,KeyboardInterrupt如果在后台线程中引发IE,则将被忽略。
ArtOfWarfare 2015年

join_with_exception如果在死线程上第二次调用,则无限期挂起。修复:github.com/fraserharris/threading-extensions/blob/master/…–
弗雷泽·哈里斯

我认为Queue没有必要;请参阅我对@Santa答案的评论。您可以将其简化为RokStrniša的答案,如下stackoverflow.com/a/12223550/126362
ejm

22

如果线程中发生异常,最好的方法是在期间在调用者线程中重新引发它join。您可以使用该sys.exc_info()函数获取有关当前正在处理的异常的信息。该信息可以简单地存储为线程对象的属性,直到join被调用为止,此时可以重新引发该信息。

请注意,Queue.Queue在这种简单的情况下,线程最多引发1个异常,在引发异常后立即完成,因此不需要(如其他答案中所建议的)。我们只需等待线程完成即可避免出现竞争情况。

例如,扩展ExcThread(在下面),覆盖excRun(而不是run)。

Python 2.x:

import threading

class ExcThread(threading.Thread):
  def excRun(self):
    pass

  def run(self):
    self.exc = None
    try:
      # Possibly throws an exception
      self.excRun()
    except:
      import sys
      self.exc = sys.exc_info()
      # Save details of the exception thrown but don't rethrow,
      # just complete the function

  def join(self):
    threading.Thread.join(self)
    if self.exc:
      msg = "Thread '%s' threw an exception: %s" % (self.getName(), self.exc[1])
      new_exc = Exception(msg)
      raise new_exc.__class__, new_exc, self.exc[2]

Python 3.x:

的3参数形式raise在Python 3中消失了,因此将最后一行更改为:

raise new_exc.with_traceback(self.exc[2])

2
为什么使用threading.Thread.join(self)代替super(ExcThread,self).join()?
理查德·莫恩(RichardMöhn)

9

concurrent.futures.as_completed

https://docs.python.org/3.7/library/concurrent.futures.html#concurrent.futures.as_completed

以下解决方法:

  • 调用异常后立即返回主线程
  • 不需要额外的用户定义类,因为它不需要:
    • 明确的 Queue
    • 在工作线程周围添加一个else

资源:

#!/usr/bin/env python3

import concurrent.futures
import time

def func_that_raises(do_raise):
    for i in range(3):
        print(i)
        time.sleep(0.1)
    if do_raise:
        raise Exception()
    for i in range(3):
        print(i)
        time.sleep(0.1)

with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
    futures = []
    futures.append(executor.submit(func_that_raises, False))
    futures.append(executor.submit(func_that_raises, True))
    for future in concurrent.futures.as_completed(futures):
        print(repr(future.exception()))

可能的输出:

0
0
1
1
2
2
0
Exception()
1
2
None

不幸的是,由于一个交易失败,不可能杀死期货以取消其他交易:

如果您执行以下操作:

for future in concurrent.futures.as_completed(futures):
    if future.exception() is not None:
        raise future.exception()

然后with捕获它,并等待第二个线程完成后再继续。以下行为类似:

for future in concurrent.futures.as_completed(futures):
    future.result()

因为future.result()如果发生一个异常,则会重新引发异常。

如果您想退出整个Python过程,则可以使用os._exit(0),但是这可能意味着您需要重构。

具有完美异常语义的自定义类

我最终在以下方面为自己编写了一个完美的接口:限制一次运行的最大线程数的正确方法?部分“带有错误处理的队列示例”。该类旨在既方便,又使您可以完全控制提交和结果/错误处理。

在Python 3.6.7,Ubuntu 18.04上进行了测试。


4

这是一个令人讨厌的小问题,我想提出自己的解决方案。我发现的其他一些解决方案(例如async.io)看起来很有希望,但也有一些黑匣子。队列/事件循环方法将您与特定实现联系在一起。但是,并发的期货源代码大约只有1000行,并且很容易理解。它使我可以轻松地解决我的问题:无需进行过多设置即可创建临时工作线程,并能够捕获主线程中的异常。

我的解决方案使用并发的期货API和线程API。它允许您创建一个工作线程,为您提供线程和未来。这样,您可以加入线程以等待结果:

worker = Worker(test)
thread = worker.start()
thread.join()
print(worker.future.result())

...或者您可以让工作人员在完成后仅发送回调:

worker = Worker(test)
thread = worker.start(lambda x: print('callback', x))

...或者您可以循环播放直到事件结束:

worker = Worker(test)
thread = worker.start()

while True:
    print("waiting")
    if worker.future.done():
        exc = worker.future.exception()
        print('exception?', exc)
        result = worker.future.result()
        print('result', result)           
        break
    time.sleep(0.25)

这是代码:

from concurrent.futures import Future
import threading
import time

class Worker(object):
    def __init__(self, fn, args=()):
        self.future = Future()
        self._fn = fn
        self._args = args

    def start(self, cb=None):
        self._cb = cb
        self.future.set_running_or_notify_cancel()
        thread = threading.Thread(target=self.run, args=())
        thread.daemon = True #this will continue thread execution after the main thread runs out of code - you can still ctrl + c or kill the process
        thread.start()
        return thread

    def run(self):
        try:
            self.future.set_result(self._fn(*self._args))
        except BaseException as e:
            self.future.set_exception(e)

        if(self._cb):
            self._cb(self.future.result())

...以及测试功能:

def test(*args):
    print('args are', args)
    time.sleep(2)
    raise Exception('foo')

2

作为Threading的入门者,我花了很长时间了解如何实现Mateusz Kobos的代码(上述)。这是一个澄清的版本,可帮助您了解如何使用它。

#!/usr/bin/env python

import sys
import threading
import Queue

class ExThread(threading.Thread):
    def __init__(self):
        threading.Thread.__init__(self)
        self.__status_queue = Queue.Queue()

    def run_with_exception(self):
        """This method should be overriden."""
        raise NotImplementedError

    def run(self):
        """This method should NOT be overriden."""
        try:
            self.run_with_exception()
        except Exception:
            self.__status_queue.put(sys.exc_info())
        self.__status_queue.put(None)

    def wait_for_exc_info(self):
        return self.__status_queue.get()

    def join_with_exception(self):
        ex_info = self.wait_for_exc_info()
        if ex_info is None:
            return
        else:
            raise ex_info[1]

class MyException(Exception):
    pass

class MyThread(ExThread):
    def __init__(self):
        ExThread.__init__(self)

    # This overrides the "run_with_exception" from class "ExThread"
    # Note, this is where the actual thread to be run lives. The thread
    # to be run could also call a method or be passed in as an object
    def run_with_exception(self):
        # Code will function until the int
        print "sleeping 5 seconds"
        import time
        for i in 1, 2, 3, 4, 5:
            print i
            time.sleep(1) 
        # Thread should break here
        int("str")
# I'm honestly not sure why these appear here? So, I removed them. 
# Perhaps Mateusz can clarify?        
#         thread_name = threading.current_thread().name
#         raise MyException("An error in thread '{}'.".format(thread_name))

if __name__ == '__main__':
    # The code lives in MyThread in this example. So creating the MyThread 
    # object set the code to be run (but does not start it yet)
    t = MyThread()
    # This actually starts the thread
    t.start()
    print
    print ("Notice 't.start()' is considered to have completed, although" 
           " the countdown continues in its new thread. So you code "
           "can tinue into new processing.")
    # Now that the thread is running, the join allows for monitoring of it
    try:
        t.join_with_exception()
    # should be able to be replace "Exception" with specific error (untested)
    except Exception, e: 
        print
        print "Exceptioon was caught and control passed back to the main thread"
        print "Do some handling here...or raise a custom exception "
        thread_name = threading.current_thread().name
        e = ("Caught a MyException in thread: '" + 
             str(thread_name) + 
             "' [" + str(e) + "]")
        raise Exception(e) # Or custom class of exception, such as MyException

2

类似于RickardSjogren的没有Queue,sys等的方式,但是也没有一些信号侦听器:直接执行与except块相对应的异常处理程序。

#!/usr/bin/env python3

import threading

class ExceptionThread(threading.Thread):

    def __init__(self, callback=None, *args, **kwargs):
        """
        Redirect exceptions of thread to an exception handler.

        :param callback: function to handle occured exception
        :type callback: function(thread, exception)
        :param args: arguments for threading.Thread()
        :type args: tuple
        :param kwargs: keyword arguments for threading.Thread()
        :type kwargs: dict
        """
        self._callback = callback
        super().__init__(*args, **kwargs)

    def run(self):
        try:
            if self._target:
                self._target(*self._args, **self._kwargs)
        except BaseException as e:
            if self._callback is None:
                raise e
            else:
                self._callback(self, e)
        finally:
            # Avoid a refcycle if the thread is running a function with
            # an argument that has a member that points to the thread.
            del self._target, self._args, self._kwargs, self._callback

仅self._callback和run()中的except块是普通threading.Thread的附加项。


2

我知道我在这里参加聚会有点晚了,但是我遇到了一个非常类似的问题,但是它包括使用tkinter作为GUI,并且mainloop使得无法使用任何依赖.join()的解决方案。因此,我改编了原始问题的EDIT中给出的解决方案,但使其变得更笼统,以使其他人易于理解。

这是正在使用的新线程类:

import threading
import traceback
import logging


class ExceptionThread(threading.Thread):
    def __init__(self, *args, **kwargs):
        threading.Thread.__init__(self, *args, **kwargs)

    def run(self):
        try:
            if self._target:
                self._target(*self._args, **self._kwargs)
        except Exception:
            logging.error(traceback.format_exc())


def test_function_1(input):
    raise IndexError(input)


if __name__ == "__main__":
    input = 'useful'

    t1 = ExceptionThread(target=test_function_1, args=[input])
    t1.start()

当然,从日志记录中,您总是可以用其他方法来处理异常,例如将其打印出来或将其输出到控制台。

这使您可以像完全使用Thread类一样使用ExceptionThread类,而无需进行任何特殊修改。


1

我喜欢的一种方法是基于观察者模式。我定义了一个信号类,我的线程使用该信号类向侦听器发出异常。它也可以用于从线程返回值。例:

import threading

class Signal:
    def __init__(self):
        self._subscribers = list()

    def emit(self, *args, **kwargs):
        for func in self._subscribers:
            func(*args, **kwargs)

    def connect(self, func):
        self._subscribers.append(func)

    def disconnect(self, func):
        try:
            self._subscribers.remove(func)
        except ValueError:
            raise ValueError('Function {0} not removed from {1}'.format(func, self))


class WorkerThread(threading.Thread):

    def __init__(self, *args, **kwargs):
        super(WorkerThread, self).__init__(*args, **kwargs)
        self.Exception = Signal()
        self.Result = Signal()

    def run(self):
        if self._Thread__target is not None:
            try:
                self._return_value = self._Thread__target(*self._Thread__args, **self._Thread__kwargs)
            except Exception as e:
                self.Exception.emit(e)
            else:
                self.Result.emit(self._return_value)

if __name__ == '__main__':
    import time

    def handle_exception(exc):
        print exc.message

    def handle_result(res):
        print res

    def a():
        time.sleep(1)
        raise IOError('a failed')

    def b():
        time.sleep(2)
        return 'b returns'

    t = WorkerThread(target=a)
    t2 = WorkerThread(target=b)
    t.Exception.connect(handle_exception)
    t2.Result.connect(handle_result)
    t.start()
    t2.start()

    print 'Threads started'

    t.join()
    t2.join()
    print 'Done'

我没有使用线程的足够经验来宣称这是一种完全安全的方法。但这对我有用,我喜欢这种灵活性。


join()之后是否断开连接?
yeeon

我没有,但是我想这将是一个好主意,因此您不会引用未使用的内容。
RickardSjogren

我注意到“ handle_exception”仍然是子线程的一部分。需要将其传递给线程调用者的方法
yeeon 2015年

1

使用裸露的例外不是一个好习惯,因为您通常会收获比讨价还价更多的东西。

我建议修改,except使其仅捕获您要处理的异常。我认为提高它并没有达到预期的效果,因为当您TheThread在外部实例化时try,如果它引发异常,则分配将永远不会发生。

相反,您可能只想提醒它并继续前进,例如:

def run(self):
    try:
       shul.copytree(self.sourceFolder, self.destFolder)
    except OSError, err:
       print err

然后,当该异常被捕获时,您可以在那里处理它。然后,当外部程序try从中捕获到异常时TheThread,您就会知道它不会是您已经处理过的异常,它将帮助您隔离处理流程。


1
好吧,如果该线程中根本没有错误,我希望整个程序通知用户存在问题并正常结束。因此,我希望主线程捕获并处理所有异常。但是,问题仍然存在,如果TheThread引发异常,则主线程的try / except仍然无法捕获该异常。我可以让线程检测到异常并返回false,指示操作未成功。那将达到相同的期望结果,但是我仍然想知道如何正确地捕获子线程异常。
潘多

1

捕获线程异常并将其传递回调用方方法的一种简单方法是将字典或列表传递给worker方法。

示例(将字典传递给worker方法):

import threading

def my_method(throw_me):
    raise Exception(throw_me)

def worker(shared_obj, *args, **kwargs):
    try:
        shared_obj['target'](*args, **kwargs)
    except Exception as err:
        shared_obj['err'] = err

shared_obj = {'err':'', 'target': my_method}
throw_me = "Test"

th = threading.Thread(target=worker, args=(shared_obj, throw_me), kwargs={})
th.start()
th.join()

if shared_obj['err']:
    print(">>%s" % shared_obj['err'])

1

用异常存储包装线程。

import threading
import sys
class ExcThread(threading.Thread):

    def __init__(self, target, args = None):
        self.args = args if args else []
        self.target = target
        self.exc = None
        threading.Thread.__init__(self)

    def run(self):
        try:
            self.target(*self.args)
            raise Exception('An error occured here.')
        except Exception:
            self.exc=sys.exc_info()

def main():
    def hello(name):
        print(!"Hello, {name}!")
    thread_obj = ExcThread(target=hello, args=("Jack"))
    thread_obj.start()

    thread_obj.join()
    exc = thread_obj.exc
    if exc:
        exc_type, exc_obj, exc_trace = exc
        print(exc_type, ':',exc_obj, ":", exc_trace)

main()

0

pygolang提供sync.WorkGroup,尤其是将异常从生成的工作线程传播到主线程。例如:

#!/usr/bin/env python
"""This program demostrates how with sync.WorkGroup an exception raised in
spawned thread is propagated into main thread which spawned the worker."""

from __future__ import print_function
from golang import sync, context

def T1(ctx, *argv):
    print('T1: run ... %r' % (argv,))
    raise RuntimeError('T1: problem')

def T2(ctx):
    print('T2: ran ok')

def main():
    wg = sync.WorkGroup(context.background())
    wg.go(T1, [1,2,3])
    wg.go(T2)

    try:
        wg.wait()
    except Exception as e:
        print('Tmain: caught exception: %r\n' %e)
        # reraising to see full traceback
        raise

if __name__ == '__main__':
    main()

运行时给出以下内容:

T1: run ... ([1, 2, 3],)
T2: ran ok
Tmain: caught exception: RuntimeError('T1: problem',)

Traceback (most recent call last):
  File "./x.py", line 28, in <module>
    main()
  File "./x.py", line 21, in main
    wg.wait()
  File "golang/_sync.pyx", line 198, in golang._sync.PyWorkGroup.wait
    pyerr_reraise(pyerr)
  File "golang/_sync.pyx", line 178, in golang._sync.PyWorkGroup.go.pyrunf
    f(pywg._pyctx, *argv, **kw)
  File "./x.py", line 10, in T1
    raise RuntimeError('T1: problem')
RuntimeError: T1: problem

该问题的原始代码就是:

    wg = sync.WorkGroup(context.background())

    def _(ctx):
        shul.copytree(sourceFolder, destFolder)
    wg.go(_)

    # waits for spawned worker to complete and, on error, reraises
    # its exception on the main thread.
    wg.wait()
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.