-
Notifications
You must be signed in to change notification settings - Fork 222
/
item_37_use_threads.py
195 lines (150 loc) · 7.76 KB
/
item_37_use_threads.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
# Item 37: Use threads for blocking I/O, avoid for parallelism
import time
from threading import Thread
import select
# The standard implementation of Python is call CPython. CPython runs a Python
# program in two steps. First, it parses and compiles the source text into
# bytecode. Then, it runs the bytecode using a stack-based interpreter. The
# bytecode interpreter has state that must be maintained and coherent while
# the Python program executes. Python enforces coherence with a mechanism
# called the global interpreter lock (GIL).
# Essentially, the GIL is a mutual-exclusion lock (mutex) that prevents
# CPython from being affected by preemptive multi-threading, where one thread
# takes control of a program by interrupting state if it comes at an
# unexpected time. The GIL prevents these interruptions and ensures that every
# bytecode instruction works correctly with the CPython implementation and its
# C-extension modules.
# The GIL has an important negative side effect. With programs written in
# languages like C++ or Java, having multiple threads of execution means your
# program could utilize multiple CPU cores at the same time. Although Python
# supports multiple threads of execution, the GIL causes only one of them to
# make forward progress at a time. This means that when you reach for threads
# to do parallel computation and speed up your Python programs, you will be
# sorely disappointed.
# For example, say you want to do something computationally intensive with
# Python. I'll use a naive number factorization algorithm as a proxy.
def factorize(number):
for i in range(1, number + 1):
if number % i == 0:
yield i
# Factoring a set of numbers in serial takes quite a long time.
numbers = [2139079, 1214759, 1516637, 1852285]
start = time.time()
for number in numbers:
list(factorize(number))
end = time.time()
print('Took %.3f seconds' % (end - start))
# Took 0.624 seconds
# Using multiple threads to do this computation would make sense in other
# languages because you could take advantage of all the CPU cores of your
# computer. Let me try that in Python. Here, I define a Python thread for
# doing the same computation as before:
class FactorizeThread(Thread):
def __init__(self, number):
super().__init__()
self.number = number
self.factors = list([])
def run(self):
self.factors = list(factorize(self.number))
# Then, I start a thread for factorizing each number in parallel.
start = time.time()
threads = []
for number in numbers:
thread = FactorizeThread(number)
thread.start()
threads.append(thread)
# Finally, I wait for all of the threads to finish.
for thread in threads:
thread.join()
end = time.time()
print('Tool %.3f seconds' % (end - start))
# Tool 0.662 seconds
# What's surprising is that this takes even longer than running factorize in
# serial. With on thread per number, you may expect less than a n times
# speedup on the dual-core machine I used to run this code. But you would
# never expect the performance of these threads to be worse when you have
# multiple CPUs to utilize. This demonstrates the effect of the GIL on
# programs running in the standard CPython interpreter.
# There are ways to get CPython to utilize multiple cores, but it doesn't
# work with the standard Thread class (see Item 41:
# "Consider concurrent.futures for true parallelism") and it can require
# substantial effort. Knowing these limitations you may wonder, why does
# Python support threads at all? There are two good reasons.
# First, multiple threads make it easy for your program to seem like it's
# doing multiple things at the same time. Managing the juggling act of
# simultaneous tasks is difficult to implement yourself (see Item 40:
# "Consider co-routines to run many functions concurrently" for an example).
# With threads, you can leave it to Python to run your functions seemingly in
# parallel. This works because CPython ensures a level of fairness between
# Python threads of execution, even though only one of them makes forward
# progress at a time due to the GIL.
# The second reason Python supports threads is to deal with blocking I/O,
# which happens when Python does certain types of system calls. System calls
# are how your Python program asks your computer's operating system to
# interact with the external environment on your behalf. Blocking I/O includes
# things like reading and writing files, interacting with networks,
# communicating with devices like displays, etc. Threads help you handle
# blocking I/O by insulating your program from the time it takes for the
# operating system to respond to your requests.
# For example, say you want to send a singal to a remote-controlled helicopter
# through a serial port. I'll use a slow system call (select) as a proxy for
# this activity. This function asks the operating system to block for 0.1
# second and then return control to my program, similar to what would happen
# when using a synchronous serial port.
def slow_systemcall():
select.select([], [], [], 0.1)
# Running this system call in serial requires a linearly increasing amount of
# time.
start = time.time()
for _ in range(5):
slow_systemcall()
end = time.time()
print('Took %.3f seconds' % (end - start))
# Took 0.501 seconds
# The problem is that while the slow_systemcall function is running, my
# program can't make any other progress. My program's main thread of execution
# is blocked on the select system call. This situation is awful in practice.
# You need to be able to compute your helicopter's next move while you're
# sending it a signal, otherwise it's crash. When you find yourself needing to
# do blocking I/O and computation simultaneously, it's time to consider moving
# your system calls to threads.
# Here, I run multiple invocation of the slow_systemcall function in separate
# threads. This would allow you to communicate with multiple serial ports (and
# helicopters) at the same time, while leaving the main thread to do whatever
# computation is required.
start = time.time()
threads = []
for _ in range(5):
thread = Thread(target=slow_systemcall)
thread.start()
threads.append(thread)
# With the threads started, here I do some work to calculate the next
# helicopter move before waiting for the system call threads to finish.
def compute_helicopter_location(index):
return index**2
for i in range(5):
compute_helicopter_location(i)
for thread in threads:
thread.join()
end = time.time()
print('Took %.3f seconds' % (end - start))
# Took 0.101 seconds
# The parallel time is 5 times less than the serial time. This shows that the
# system call will all run in parallel from multiple Python threads even
# though they're limited by the GIL. The GIL prevents my Python code from
# running in parallel, but it has no negative effect on system calls. This
# works because Python threads release the GIL just before they make system
# calls and reacquire the GIL as soon as the system calls are done.
# There are many other ways to deal with blocking I/O besides threads, such as
# the asyncio built-in module, and these alternatives have important benefits.
# But these options also require extra work in refactoring your code to fit a
# different model of execution (see Item 40: "Consider coroutines to run many
# functions concurrently"). Using threads is the simplest way to do blocking
# I/O in parallel with minimal changes to your program.
# Things to remember
# 1. Python threads can't bytecode in parallel on multiple CPU cores because
# of the global interpreter lock (GIL).
# 2. Python threads are still useful despite the GIL because they provide an
# easy way to do multiple things at seemingly the same time.
# 3. Use Python threads to make multiple system calls in parallel. This allows
# you to do blocking I/O at the same time as computation.