|
| 1 | +原文:[Freeze your Python with str.encode and threads](https://blog.sqreen.io/freeze-python-str-encode-threads/) |
| 2 | + |
| 3 | +--- |
| 4 | + |
| 5 | +[](https://blog.sqreen.io/wp-content/uploads/2016/07/frozen-python-2040.png "frozen-python-2040") |
| 6 | + |
| 7 | +当我在[sqreen.io Python agent](https://www.sqreen.io/?utm_medium=social&utm_source=blog&utm_campaign=Freeze%20your%20Python%20with%20str.encode%20and%20threads)工作的时候,我发现了一个相当讨厌,但是分析起来却挺有意思的bug,它让我深入到Python内部。 |
| 8 | + |
| 9 | +### 这个讨厌的bug |
| 10 | + |
| 11 | +我正在写一段代码,它产生一个线程,检索一些JSON,然后存储以用于后续使用。基本上,代码看起来是这样的: |
| 12 | + |
| 13 | +```python |
| 14 | +from threading import Thread, Event |
| 15 | + |
| 16 | +class MyThread(Thread): |
| 17 | + |
| 18 | + def __init__(self): |
| 19 | + self.event = Event() |
| 20 | + |
| 21 | + def run(self): |
| 22 | + data = self.retrieve_json() |
| 23 | + self.data = data |
| 24 | + self.event.set() |
| 25 | + print("Data retrieval done!") |
| 26 | + while True: |
| 27 | + pass |
| 28 | + |
| 29 | +thread = MyThread() |
| 30 | +thread.start() |
| 31 | +timeout = thread.event.wait(5) |
| 32 | + |
| 33 | +if timeout is not None: |
| 34 | + print("JSON retrieval was too slow") |
| 35 | +``` |
| 36 | + |
| 37 | +一切似乎都还好,并且当网络延迟过高的时候,错误信息会正确显示: |
| 38 | + |
| 39 | +```sh |
| 40 | +$> python2 app.py |
| 41 | +Data retrieval done! |
| 42 | +``` |
| 43 | + |
| 44 | +接下来,我想将字典键和值从unicode转换成latin-1字符串: |
| 45 | + |
| 46 | +```python |
| 47 | +class MyThread(Thread): |
| 48 | + |
| 49 | + def run(self): |
| 50 | + data = self.retrieve_json() |
| 51 | + # Convert unicode strings into latin1 strings because of WSGI |
| 52 | + self.data = {key.encode('latin1'): value.encode('latin1') for (key, value) in data.keys()} |
| 53 | + self.event.set() |
| 54 | + print("Data retrieval done!") |
| 55 | + while True: |
| 56 | + pass |
| 57 | +``` |
| 58 | + |
| 59 | +我敢肯定,在接收JSON时,你们都做过这种类型的转换,不是吗? |
| 60 | + |
| 61 | +添加这个代码后,无论我提供了什么样子的值,错误信息开始显示超时,从5s到1小时。而更诡异的是,`"Data retrieval done!"`信息总是在错误信息之后打印出来: |
| 62 | + |
| 63 | +```sh |
| 64 | +$> python2 app.py |
| 65 | +JSON retrieval was too slow |
| 66 | +Data retrieval done! |
| 67 | +``` |
| 68 | + |
| 69 | +然而,当我在Python 3上跑的时候,一切如我所预期: |
| 70 | + |
| 71 | +```sh |
| 72 | +$> python3 app.py |
| 73 | +Data retrieval done! |
| 74 | +``` |
| 75 | + |
| 76 | +一些非常奇怪的事情一直发生,而我决定调查一下。 |
| 77 | + |
| 78 | +### 隔离 |
| 79 | + |
| 80 | +我试图找出问题所在,于是有了这个最小化的再现脚本,让我们把它命名为`"str_encode_thread.py"`: |
| 81 | + |
| 82 | +```python |
| 83 | +import time |
| 84 | +import threading |
| 85 | + |
| 86 | + |
| 87 | +def to_latin_1(event, value): |
| 88 | + print(time.time(), "Before encode") |
| 89 | + value.encode('latin-1') |
| 90 | + print(time.time(), "After encode") |
| 91 | + event.set() |
| 92 | + print(time.time(), "Event set") |
| 93 | + |
| 94 | +event = threading.Event() |
| 95 | +thread = threading.Thread(target=to_latin_1, args=(event, u'42')) |
| 96 | +thread.start() |
| 97 | + |
| 98 | +event.wait(10) |
| 99 | +``` |
| 100 | + |
| 101 | +如果你用Python 3启动这个脚本,将会看到: |
| 102 | + |
| 103 | +```sh |
| 104 | +$> python3 str_encode_thread.py |
| 105 | +1467046041.643346 Before encode |
| 106 | +1467046041.643446 After encode |
| 107 | +1467046041.643483 Event set |
| 108 | +``` |
| 109 | + |
| 110 | +而用Python 2,你会看到: |
| 111 | + |
| 112 | +```sh |
| 113 | +$> python2 str_encode_thread.py |
| 114 | +(1467046099.495261, 'Before encode') |
| 115 | +(1467046099.495937, 'After encode') |
| 116 | +(1467046099.495961, 'Event set') |
| 117 | +``` |
| 118 | + |
| 119 | +目前一切OK。而当我们尝试导入这个脚本时,好玩的事情来了。如果你用Python 3导入这个脚本,那么一切仍然是正常的: |
| 120 | + |
| 121 | +```sh |
| 122 | +$> time python3 -c "import str_encode_thread" |
| 123 | +1467046239.921475 Before encode |
| 124 | +1467046239.921578 After encode |
| 125 | +1467046239.921602 Event set |
| 126 | +python3 -c "import str_encode_thread" 0,05s user 0,01s system 84% cpu 0,081 total |
| 127 | +``` |
| 128 | + |
| 129 | +而用Python 2导入时,我们讨厌的朋友再次出现了: |
| 130 | + |
| 131 | +```sh |
| 132 | +$> time python2 -c "import str_encode_thread" |
| 133 | +(1467046276.826755, 'Before encode') |
| 134 | +(1467046286.830485, 'After encode') # <- 10 seconds (╯°□°)╯︵ ┻━┻ |
| 135 | +(1467046286.830519, 'Event set') |
| 136 | +python2 -c "import str_encode_thread" 0,03s user 0,03s system 0% cpu 10,109 total |
| 137 | +``` |
| 138 | + |
| 139 | +如果你仔细看看`"After encode"`消息,可以看到,它在`"Before encode"`消息打印10s之后才打印,这意味着线程莫名其妙被冻结了。 |
| 140 | + |
| 141 | +### 掉进兔子洞,不知如何是好 |
| 142 | + |
| 143 | +为嘛一个简单的`"value.encode('latin1', 'encode')"`会阻塞一个线程呢? |
| 144 | + |
| 145 | +幸好,有一个[非常棒的名为faulthandler的包](https://pypi.python.org/pypi/faulthandler/2.4),它在这种情况下非常有用。它可以在unix信号、用户信号或者python错误上展示回溯。从Python 3.3开始,它就是标准库的一部分了,而对于老一点的Python版本,可以用pip进行安装。 |
| 146 | + |
| 147 | +本例中,我们将使用[dump_traceback_later](http://faulthandler.readthedocs.io/#dumping-the-tracebacks-after-a-timeout)来看看该线程在哪里阻塞。 |
| 148 | + |
| 149 | +带faulthandler的新脚本并没有什么不同 (不要忘了`"pip install faulthandler"`): |
| 150 | + |
| 151 | +```python |
| 152 | +import time |
| 153 | +import threading |
| 154 | +import faulthandler |
| 155 | + |
| 156 | + |
| 157 | +faulthandler.dump_traceback_later(5) |
| 158 | + |
| 159 | + |
| 160 | +def to_latin_1(event, value): |
| 161 | + print(time.time(), "Before encode") |
| 162 | + value.encode('latin-1') |
| 163 | + print(time.time(), "After encode") |
| 164 | + event.set() |
| 165 | + print(time.time(), "Event set") |
| 166 | + |
| 167 | +event = threading.Event() |
| 168 | +thread = threading.Thread(target=to_latin_1, args=(event, u'42')) |
| 169 | +thread.start() |
| 170 | + |
| 171 | +event.wait(10) |
| 172 | +``` |
| 173 | + |
| 174 | +让我们运行新的脚本: |
| 175 | + |
| 176 | +```sh |
| 177 | +time python2 -c "import str_encode_thread" |
| 178 | +(1468083195.13011, 'Before encode') |
| 179 | +Timeout (0:00:05)! |
| 180 | +Thread 0x0000700000401000 (most recent call first): |
| 181 | + File "/usr/lib/python2.7/encodings/__init__.py", line 100 in search_function |
| 182 | + File "str_encode_thread.py", line 11 in to_latin_1 |
| 183 | + File "/usr/lib/python2.7/threading.py", line 754 in run |
| 184 | + File "/usr/lib/python2.7/threading.py", line 801 in __bootstrap_inner |
| 185 | + File "/usr/lib/python2.7/threading.py", line 774 in __bootstrap |
| 186 | + |
| 187 | +Current thread 0x00007fff71258000 (most recent call first): |
| 188 | + File "/usr/lib/python2.7/threading.py", line 359 in wait |
| 189 | + File "/usr/lib/python2.7/threading.py", line 614 in wait |
| 190 | + File "str_encode_thread.py", line 20 in <module> |
| 191 | + File "<string>", line 1 in <module> |
| 192 | +(1468083205.133041, 'After encode') |
| 193 | +(1468083205.133106, 'Event set') |
| 194 | +python2 -c "import str_encode_thread" 0,03s user 0,02s system 0% cpu 10,043 total |
| 195 | +``` |
| 196 | + |
| 197 | +回溯中我们能看到什么? |
| 198 | + |
| 199 | +主线程`"0x00007fff71258000"`在我们模块的第20行阻塞了,在那里是`"event.wait(10)"`,这完全正常。 |
| 200 | + |
| 201 | +其他线程`"0x0000700000401000"`正运行我们模块的第11行`"value.encode('latin-1')"`,并且调用内置模块`"encoding"`的函数`"search_function"`,它冻结了我们的线程。这个函数做了什么,为什么它是阻塞的? |
| 202 | + |
| 203 | +[源代码](https://hg.python.org/cpython/file/v2.7.12rc1/Lib/encodings/__init__.py#l99)告诉我们,它试图导入一个以`"encodings."`开头的模块。 |
| 204 | + |
| 205 | +```python |
| 206 | +In [1]: import sys |
| 207 | + |
| 208 | +In [2]: before_modules = sys.modules.keys() |
| 209 | + |
| 210 | +In [3]: '42'.encode('latin-1') |
| 211 | +Out[3]: '42' |
| 212 | + |
| 213 | +In [4]: after_modules = sys.modules.keys() |
| 214 | + |
| 215 | +In [5]: print("Imported modules", set(after_modules) - set(before_modules)) |
| 216 | +('Imported modules', set(['encodings.latin_1'])) |
| 217 | +``` |
| 218 | + |
| 219 | +明白了!`"str.encode(X)"`试图导入模块`"encodings.X"`。所以修复应该是容易的:在主线程中,使用该函数之前导入该模块,这样就可以了,是吧? |
| 220 | + |
| 221 | +```python |
| 222 | +import time |
| 223 | +import threading |
| 224 | +import encodings.latin_1 |
| 225 | + |
| 226 | + |
| 227 | +def to_latin_1(event, value): |
| 228 | + print(time.time(), "Before encode") |
| 229 | + value.encode('latin-1') |
| 230 | + print(time.time(), "After encode") |
| 231 | + event.set() |
| 232 | + print(time.time(), "Event set") |
| 233 | + |
| 234 | +event = threading.Event() |
| 235 | +thread = threading.Thread(target=to_latin_1, args=(event, u'42')) |
| 236 | +thread.start() |
| 237 | + |
| 238 | +event.wait(10) |
| 239 | +``` |
| 240 | + |
| 241 | +让我们用python2再试试: |
| 242 | + |
| 243 | +```sh |
| 244 | +$> time python2 -c "import str_encode_thread" |
| 245 | +(1467049098.995541, 'Before encode') |
| 246 | +(1467049109.000293, 'After encode') |
| 247 | +(1467049109.000324, 'Event set') |
| 248 | +python2 -c "import str_encode_thread" 0,03s user 0,02s system 0% cpu 10,054 total |
| 249 | +``` |
| 250 | + |
| 251 | +我们仍然有相同的问题!怎么了? |
| 252 | + |
| 253 | +### 必须更深入 |
| 254 | + |
| 255 | +我们知道,`"str.encode"`试图导入一个可能冻结我们线程的模块。 |
| 256 | + |
| 257 | +在这一点上,我到我最喜欢的IRC频道 (#python-fr)寻求帮助,最终我们找到了原因! |
| 258 | + |
| 259 | +`"import module"`做了什么?如果我们相信[Python文档](https://docs.python.org/2/library/imp.html#examples),那它基本上做了这些: |
| 260 | + |
| 261 | +```python |
| 262 | +import imp |
| 263 | +import sys |
| 264 | + |
| 265 | +def __import__(name, globals=None, locals=None, fromlist=None): |
| 266 | + # Fast path: see if the module has already been imported. |
| 267 | + try: |
| 268 | + return sys.modules[name] |
| 269 | + except KeyError: |
| 270 | + pass |
| 271 | + |
| 272 | + # If any of the following calls raises an exception, |
| 273 | + # there's a problem we can't handle -- let the caller handle it. |
| 274 | + |
| 275 | + fp, pathname, description = imp.find_module(name) |
| 276 | + |
| 277 | + try: |
| 278 | + return imp.load_module(name, fp, pathname, description) |
| 279 | + finally: |
| 280 | + # Since we may exit via an exception, close fp explicitly. |
| 281 | + if fp: |
| 282 | + fp.close() |
| 283 | +``` |
| 284 | + |
| 285 | +没有啥特别的:如果已经导入了该模块,那么从`"sys.modules"`返回该模块,否则,尝试找到该模块的源文件,加载并返回。 |
| 286 | + |
| 287 | +这里也不应该阻塞,但若我们继续看`"imp"`模块文档,可以找到一个[非常有趣的名为`"lock_held"`的函数](https://docs.python.org/2/library/imp.html#imp.lock_held): |
| 288 | + |
| 289 | +> 在多线程平台上,执行导入的线程持有一个内部锁,直到导入完成。这个锁阻塞了其他线程进行导入,直到初始导入完成,这反过来阻止其他线程看到由初始线程在完成导入(以及可能有的由此导入触发的其他导入)过程中构建的未完成的模块对象。 |
| 290 | +
|
| 291 | +真相越来越近…… |
| 292 | + |
| 293 | +### 锁和线程,爱恨交加 |
| 294 | + |
| 295 | +当搜索`"python import lock"`,我们可以迅速找到关于[在线程代码中导入](https://docs.python.org/2/library/threading.html#importing-in-threaded-code)的章节,它只是告诉你这是一个可怕的想法,并有可能造成死锁。 |
| 296 | + |
| 297 | +让我们总结下我们的发现: |
| 298 | + |
| 299 | +```python |
| 300 | +import imp |
| 301 | +import time |
| 302 | +import threading |
| 303 | + |
| 304 | + |
| 305 | +def to_latin_1(event, value): |
| 306 | + print(time.time(), "Before encode", threading.current_thread().name, imp.lock_held()) |
| 307 | + value.encode('latin-1') |
| 308 | + print(time.time(), "After encode") |
| 309 | + event.set() |
| 310 | + print(time.time(), "Event set") |
| 311 | + |
| 312 | +event = threading.Event() |
| 313 | +thread = threading.Thread(target=to_latin_1, args=(event, u'42')) |
| 314 | +thread.start() |
| 315 | + |
| 316 | +event.wait(10) |
| 317 | +``` |
| 318 | + |
| 319 | +```sh |
| 320 | +$> python2 -c "import str_encode_thread" |
| 321 | +(1467050582.565935, 'Before encode', 'Thread-1', True) |
| 322 | +(1467050592.567373, 'After encode') |
| 323 | +(1467050592.567418, 'Event set') |
| 324 | +``` |
| 325 | + |
| 326 | +当我们导入我们的模块`"str_encode_thread"`时,导入锁由主线程获得,因此当我们的自定义线程试图执行`"value.encode('latin-1')"`时,stdlib试图导入`"encoding.latin_1"`模块,这会被阻塞,因为导入锁已经在主线程手里。然后,我们通过`"event.wait(10)"`阻塞了主线程,接着这两个线程都阻塞了10秒,太糟糕了……当等待超时,`"import str_encode_thread"`结束,释放导入锁,从而唤醒了我们的自定义线程。 |
| 327 | + |
| 328 | +如果我们没有设置一个最大化的事件超时,我们将永远让Python解释器死锁! |
| 329 | + |
| 330 | +在处理线程时,并无神奇解决方案。如果你手动导入一个模块而无需使用锁,那么你可能不知道发生了什么,但是它只会通过抛出额外有趣的bug来“解决”死锁。 |
| 331 | + |
| 332 | +例如,当你调用`"time.strptime"`时,它试图以一种不安全的方式导入`"_strptime"`模块,这可能会引发一些难以调试的问题,像[这个boto问题](https://github.com/boto/boto/issues/1898)和[对应的Python bug页面](http://bugs.python.org/issue7980)。这个问题可以通过在主线程之前`"import _strptime"`,这样线程就不会因为锁或者看到一个未完成的模块而阻塞,从而解决。 |
| 333 | + |
| 334 | +### **Python 3**才是未来 |
| 335 | + |
| 336 | +那为什么在Python 3就一切正常?除了打破你所有的导入名,强制你在代码中到处用`".encode"`和`".decode" (这是件好事!)之外,Python 3对导入锁进行了重构,而[现在每个模块都处理全局导入锁](https://mail.python.org/pipermail/python-dev/2013-August/127902.html)。现在,你不大可能触发这个问题了。 |
| 337 | + |
| 338 | +### 我们都应该使用**Python 3** |
| 339 | + |
| 340 | +这可能是一个非常讨厌的问题,但是深入Python的一个鲜为人知的机制却是非常有趣的。 |
| 341 | + |
| 342 | +**TL;DR**: Python 2中,还有一个可重入锁,当该锁由其他线程持有时,如果你的线程试图进行导入,它将阻塞它们。Python 3中,该导入锁现在是每个模块持有的。 |
0 commit comments