1. GIL Global Interpreter Lock
1.1 Concepts
'''
In CPython, the global interpreter lock, or GIL, is a mutex that prevents multiple
native threads from executing Python bytecodes at once. This lock is necessary mainly
because CPython’s memory management is not thread-safe. (However, since the GIL
exists, other features have grown to depend on the guarantees that it enforces.)
Conclusion:In the Cpython interpreter, multiple threads opened under the same process can only have one thread running at the same moment, and cannot take advantage of multi-core benefits
'''
GIL is a purely theoretical knowledge and there is no need to take it into account during programming.
How to understand the existence of mutual exclusion locks: without mutual exclusion locks open multi-threaded, the first thread of the code to run before the second thread to run the code (in the sub-thread function function does not sleep on the premise)
1.2 Code understanding of GIL locks: no mutual exclusion locks for multiple threads, no hibernation
This example verifies that a GIL lock exists and that a thread can't run until the next thread has finished, i.e., it proves that a lock exists!
from threading import Thread
num = 6
def work():
global num
temp = num
print(f'{num} before hibernation')
num = temp - 1
print(f'{num} after hibernation')
def create_thread():
print(f'Before the change it was {num}')
task_list = [Thread(target=work) for i in range(6)]
[() for task in task_list]
[() for task in task_list]
print(f'Modified to {num}')
if __name__ == '__main__':
create_thread()
1.3 Other comparisons: multiple threads without mutual exclusion locks, with hibernation
import time
from threading import Thread
num = 6
def work():
global num
temp = num
print(f'{num} before hibernation')
(0.001) # Hibernation serves to switch threads
num = temp - 1
print(f'{num} after hibernation')
def create_thread():
print(f'Before the change it was {num}')
task_list = [Thread(target=work) for i in range(6)]
[() for task in task_list]
[() for task in task_list]
print(f'Modified to {num}')
if __name__ == '__main__':
create_thread()
1.4 Other case comparisons: multi-threaded mutual exclusion and locking, with hibernation
import time from threading import Thread, Lock num = 6 def work(lock): () global num temp = num print(f'{num} before hibernation') (0.001) # Hibernation serves to switch threads; after adding a lock, hibernation no longer switches threads. num = temp - 1 print(f'{num} after hibernation') () def create_thread(): lock = Lock() print(f'Before the change it was {num}') task_list = [Thread(target=work, args=(lock,)) for i in range(6)] [() for task in task_list] [() for task in task_list] print(f'Modified to {num}') if __name__ == '__main__': create_thread()
1.5 Other comparisons: adding automatic mutual exclusion locks
The sub-thread starts, then goes to grab the GIL lock, enters the IO and releases the GIL lock automatically, but its own lock is not yet unlocked, and other thread resources can grab the GIL lock, but not the mutex lock.
Eventually, GIL goes back to the process with the mutex lock and processes the data.
import time
from threading import Thread, Lock
num = 6
lock = Lock()
def work():
with lock: # Plus auto-lock.
global num
temp = num
print(f'{num} before hibernation')
(0.001) # Hibernation serves to switch threads; after the lock is added, hibernation no longer switches threads.
num = temp - 1
print(f'{num} after hibernation')
def create_thread():
print(f'Before the change it was {num}')
task_list = [Thread(target=work) for i in range(6)]
[() for task in task_list]
[() for task in task_list]
print(f'Modified to {num}')
if __name__ == '__main__':
create_thread()
1.6 GIL global interpreter locks prevent multithreading from taking advantage of multicores
In the Cpython interpreter GIL is a mutual exclusion lock used to prevent multiple threads under the same process from running at the same time.
Memory management in Cpython (garbage collection mechanisms: reference counting, tagging and clearing, generational recycling) is not thread-safe.
Is multithreading under the same process useless because it can't take advantage of multicore? Whether multithreading is useful or not depends on the situation.
(1) Computationally intensive
Time Consumption under Computationally Intensive Multiprocessing
import time
import os
from multiprocessing import Process
def work_calculate():
result = 1
for i in range(1, 90):
result *= i
# print(f 'The result of the calculation is {result}')
def cal_time(func):
def inner(*args, **kwargs):
start = ()
res = func(*args, **kwargs)
end = ()
print(f'Total time spent on function {func.__name__}: {end - start}')
return res
return inner
@cal_time
def create_process():
print(f'Number of CPUs in use {os.cpu_count()}')
task_list = [Process(target=work_calculate) for i in range(90)]
[() for task in task_list]
[() for task in task_list]
if __name__ == '__main__':
create_process()
Time Consumption under Computationally Intensive Multithreading
import time
import os
from threading import Thread
def work_calculate():
result = 1
for i in range(1, 90):
result *= i
# print(f'The result of the calculation is {result}')
def cal_time(func):
def inner(*args, **kwargs):
start = ()
res = func(*args, **kwargs)
end = ()
print(f'Total time spent on function {func.__name__}: {end - start}')
return res
return inner
@cal_time
def create_thread():
print(f'Number of CPUs in use {os.cpu_count()}')
task_list = [Thread(target=work_calculate) for i in range(90)]
[() for task in task_list]
[() for task in task_list]
if __name__ == '__main__':
create_thread()
(2) IO-intensive
Mimicking IO Intensity: Multiple IO Switching Operations
Time-consumption under IO-intensive multiprocessing
import os
import time
from multiprocessing import Process
def io_switch():
# print('Subprocess started running')
(1)
# print('Subprocess ended running')
def cal_time(func):
def inner(*args, **kwargs):
start = ()
res = func(*args, **kwargs)
end = ()
print(f'The function {func.__name__} takes {end - start} to run.')
return res
return inner
@cal_time
def create_process():
print(f'The number of running CPUs is {os.cpu_count()}')
task_list = [Process(target=io_switch) for i in range(90)]
[() for task in task_list]
[() for task in task_list]
if __name__ == '__main__':
create_process()
Time-consumption under IO-intensive multithreading
import os
import time
from threading import Thread
def io_switch():
# print('Subthread started running')
(1)
# print('Subthread ended running')
def cal_time(func):
def inner(*args, **kwargs):
start = ()
res = func(*args, **kwargs)
end = ()
print(f'The function {func.__name__} takes {end - start} to run.')
return res
return inner
@cal_time
def create_thread():
print(f'The number of running CPUs is {os.cpu_count()}')
task_list = [Thread(target=io_switch) for i in range(90)]
[() for task in task_list]
[() for task in task_list]
if __name__ == '__main__':
create_thread()
(3) Computation-intensive vs. IO-intensive theories
[1] Computationally intensive tasks (multi-process)
Computation-intensive tasks are mainly those that require a large amount of CPU computational resources, which include executing code, performing arithmetic operations, loops, etc.
In this case, there is not much advantage to using multithreading.
Since Python has a Global Interpreter Lock (GIL), only one thread can be executing code at the same moment, which means that in the case of multi-threading, only one thread is performing a computationally intensive task at the same moment.
However, if you use multi-processing, you can take full advantage of multi-core CPUs.
Each process has its own independent GIL lock, so multiple processes can perform computationally intensive tasks simultaneously, fully utilizing the power of multi-core CPUs.
By turning on multiple processes, we can assign computationally intensive tasks to each process, allowing each process to perform the task alone, thereby increasing overall computational efficiency.
[2] IO-intensive tasks (multithreading)
IO-intensive tasks are mainly tasks that involve a large number of input and output operations (e.g., opening files, writing files, network operations, etc.).
In this case, the thread tends to release CPU execution privileges as it waits for the IO operation, without causing much waste of CPU resources.
As a result, using multithreading allows for better handling of IO-intensive tasks and avoids the overhead of switching processes frequently.
When we open multiple IO-intensive threads in a process, most of the threads are in a waiting state, and opening multiple processes does not improve efficiency, but consumes more system resources.
Therefore, in IO-intensive tasks, the use of multiple threads is sufficient to meet the demand without opening multiple processes.
[3] Summary
Compute-intensive tasks: use multiple processes to take full advantage of multi-core CPUs, the more CPUs the better!
IO-intensive tasks: using multithreading can better handle IO operations and avoid the overhead of frequent process switches.
Choosing the appropriate concurrency method according to the characteristics of the task can effectively improve the execution efficiency of the task.
Calculations consume a lot of cpu: code execution, arithmetic, for are all calculations
io consumes less cpu: opening files, writing files, network operations are all io
If an io is encountered, the thread releases the cpu's execution privileges, and the cpu moves on to execute another thread
Since python has a gil lock, when multiple threads are opened, only one thread can be executing at the same time
If it's computationally intensive, with multiple threads on, at the same moment, only one thread is executing
Multi-core cpu, it would be a waste of multi-core advantage
If it's computationally intensive, we'd like to have multiple cores working on the same process without gilocking.
So we turn on multiprocessing, gil locks can only lock threads in one process, turn on multiple processes and you can take advantage of the multi-core advantage
io intensive: cpu execution privileges are released whenever an io is encountered
Processes open more than one io thread, most of the threads are waiting, open multi-process can not improve efficiency, but open the process is very resource-consuming, so use multi-threaded can be
1.7 Summary of GIL features
GIL is not a feature of python, but of the Cpython interpreter
GIL has a narrow role, limited to securing data at the interpreter level;
Custom locks are used to secure a wider range of data.
GIL can result in multiple threads under the same process not being able to proceed at the same time, i.e., not being able to take advantage of multi-core benefits
Different locks are required for different data.
Common shortcoming of interpreted languages: multiple threads in the same process cannot take advantage of multi-core benefits
2. Deadlocks
2.1 Concepts
Deadlock is a phenomenon in which two or more processes (threads), while running, wait for each other due to competition for resources.
Two or more processes (threads) hold their own locks and try to acquire the locks held by the other, resulting in blocking and the inability to run code backwards.
Solution: If a deadlock problem occurs, one party must be made to surrender the lock first.
2.2 Code Examples
import time
from threading import Thread, Lock
# Classes are bracketed multiple times, each time producing a different object If you want to realize that multiple bracketing yields the same object - the singleton pattern
lock_a = Lock()
lock_b = Lock()
class NewThread(Thread):
def run(self):
self.work_one() # Running subthreaded functions
self.work_two()
def work_one(self):
lock_a.acquire() # Take the lock first.
print(f'{}Got the lock a') # The Thread class has a name attribute to get the name of the thread.
lock_b.acquire() # Take the lock again.
print(f'{}Got the lock.')
lock_b.release() # Release the lock first.
print(f'{} released the lock b')
lock_a.release() # Release the lock againa
print(f'{}Released the lock a')
def work_two(self):
lock_b.acquire() # Take the lock first.
print(f'{\pos(192,230)}Got lock B. Starting to sleep in .001 seconds.')
(0.001)
print(f'{}Sleep is over.')
lock_a.acquire() # Take the lock again.
print(f'{}Got the lock a')
lock_a.release() # Release the locka
print(f'{}Released the lock a')
lock_b.release()
print(f'{} released the lock b')
# Define the spawn subthread function
def create_thread():
st_list = [NewThread() for i in range(1, 3)]
[() for st in st_list]
if __name__ == '__main__':
create_thread()
Analysis:
Functions are run in the following order: first run all the code in one function, then run the second function.
Thread 1 runs to acquire lock a, and will switch to thread 2, which will switch to thread 3 because it can't acquire lock a. Thread 3 can't acquire lock a, and switches to thread 1, which executes the code after acquiring lock a.
Acquiring lock b - releasing lock b - releasing lock a
---- Thread 1 acquires lock b (lock b is not released)
--- switch to thread 2 --- thread 2 acquires lock a (lock a not released)
---switched to thread 3 ---thread 3 can't acquire lock a
---switched to thread 1---Print "Thread 1 got lock b and started sleeping for 0.001 seconds"
--- hibernate, switch to thread 2 ---Print "Thread 2 got lock a."---- Thread 2 can't acquire the lockb
---switched to thread 3 ---thread can't acquire lock a
---switched to thread 1---Print "Thread 1 End of Sleep"---- Thread 1 can't acquire lock a
--- switch to thread 2 --- thread 2 still can't acquire the lock b --- switch to thread 3 --- thread 3 still can't acquire the lock a --- the program enters a blocking state
3. Recursive locks
3.1 Concepts
Recursive locks (also known as re-entrant locks) are special locks that allow a thread to request the same lock multiple times, called "recursive" request locks.
Before this thread releases the lock, it will perform an accumulating operation on the lock counter, and every time the thread successfully acquires a lock, it will have to perform the corresponding unlocking operation until the lock counter is cleared to zero before it can completely release the lock.
Recursive locking avoids deadlocks by ensuring that the same thread holding a lock can acquire it again without being blocked by the lock it holds.
But be careful to use recursive locks normally to avoid excessive lock acquisitions that can lead to performance degradation.
can be successively acquired and released.But it can only be performed by the first thread that acquires the lock.
There is an internal counter, acquire counts +1 once, release counts -1 once.
As long as the count is not 0, no other thread can acquire the lock.
3.2 Code Examples
On top of the deadlock, just change the Lock module to an RLock module and have both locks point to the same lock object
import time
from threading import Thread, RLock
lock_a = lock_b = RLock() # Two variable names pointing to the same lock
class NewThread(Thread):
def run(self):
self.func1()
self.func2()
def func1(self):
lock_a.acquire()
print(f'{}Acquired lock a') # Get thread name
lock_b.acquire()
print(f'{}Acquired lock b')
lock_b.release()
print(f'{} released the lock b')
lock_a.release()
print(f'{}Released the lock a')
def func2(self):
lock_b.acquire()
print(f'{\pos(192,230)}Got lock B. Starting to sleep in .001 seconds.')
(0.001)
print(f'{}Sleep is over.')
lock_a.acquire()
print(f'{}Got the lock a')
lock_a.release() #
print(f'{}Released the lock a')
lock_b.release()
print(f'{} released the lock b')
# Define the spawn subthread function
def create_thread():
st_list = [NewThread() for i in range(1, 3)]
[() for st in st_list]
if __name__ == '__main__':
create_thread()
Analysis:
According to the definition of recursive locks: a thread can request the same lock multiple times
Thread 1 acquires lock a --- switches to thread 2 --- thread 2 fails to acquire lock a --- switches to thread 3 --- thread 3 fails to acquire lock a --- switches to thread 1 --- thread 1 acquires lock b --- thread 1 releases lock b --- thread 1 releases lock a (all locks released)
Thread 1 acquires lock b (not released) --- switches to thread 2 --- thread 2 cannot acquire lock a (lock a lock b is the same lock object) --- switches to thread 3 --- thread 3 cannot acquire lock a --- switches to thread 1
Thread 1 sleeps - will switch threads - other threads still can't acquire the lock a - switch to thread 1 - thread 1 acquires the lock a - thread 1 releases the lock a - thread 1 releases the lock b - thread 1 function function all code runs through
Therefore, the code is run in the order that the code in one thread is finished before running the code in the other thread
4. Signalization (Awareness point)
4.1 Concepts
Semaphore, a semaphore module, is available in both processes and threads.
GIL locks allow only one thread to run at a time, while semaphores allow a certain number of threads to run at a time
Example: There is a very long railroad, the GIL lock is that only 1 train can run on that railroad at the same moment; the signal quantity is that a specified number of trains can be run, and when there is 1 train running to the station, the waiting train can send 1 car.
The concepts of semaphores and process pools are more similar, but it's important to distinguish between them; semaphores involve the concepts of acquiring and releasing.
4.2 Signals in a process
[1] No dormancy
from multiprocessing import Process, Semaphore
def train(sem, num):
() # Get Signal
print(f'Train {num} begins to depart')
print(f'Train {num} arrival')
() # RELEASE SIGNALS
def create_process(sem):
sp_list = [Process(target=train, args=(sem, i)) for i in range(1, 10)]
[() for sp in sp_list]
[() for sp in sp_list]
if __name__ == '__main__':
signal = Semaphore(3) # Generate a semaphore object, default parameter is 1
create_process(sem=signal)
[2] With dormancy
import time
from multiprocessing import Process, Semaphore
def train(sem, num):
() # Getting a semaphore
print(f'Train {num} begins to depart')
(1)
print(f'Train {num} arrival')
() # RELEASE SIGNALS
def create_process(sem):
sp_list = [Process(target=train, args=(sem, i)) for i in range(1, 10)]
[() for sp in sp_list]
[() for sp in sp_list]
if __name__ == '__main__':
signal = Semaphore(3) # Generate a semaphore object, default parameter is 1
create_process(sem=signal)
4.3 Signals in threads
import time
from threading import Thread, Semaphore
def train(sem, num):
() # Getting a semaphore
print(f'Train {num} begins to depart')
(1)
print(f'Train {num} arrival')
() # Release of semaphore
def create_thread(sem):
st_list = [Thread(target=train, args=(sem, i)) for i in range(1, 10)]
[() for st in st_list]
[() for st in st_list]
if __name__ == '__main__':
signal = Semaphore(3) # Generate a semaphore object, the default parameter is 1
create_thread(sem=signal)
5. Event events (understanding of knowledge points)
5.1 Concepts
The role of events in python threads: a thread can control the running of another thread
The event provides three methods: set, wait, and clear.
Mechanisms for handling events:
A "Flag" is defined globally with an initial value of Flase.
If the value of "Flag" is False, the program runs until it blocks.
If the value of "Flag" is True, the program runs without blocking.
clear: Set the "Flag" value to False.
set: Set the "Flag" value to True.
5.2 Code Examples
import time
from threading import Thread, Event
# Define Train Signal Thread Function Functions
def railway_light(event):
print('Red light is on. All trains are waiting.')
(10)
print('Green light. All trains depart.')
()
# Define the train departure thread function
def train(event, num):
print(f'Train {num} waiting for departure signal')
()
print(f'Train {num} departs')
# Define the spawn subthread function
def create_thread():
signal = Event() # Generating event objects
railway_light_thread = Thread(target=railway_light, args=(signal,)) # Generate semaphore subthreads
railway_light_thread.start()
train_thread_list = [Thread(target=train, args=(signal, i)) for i in range(1, 10)]
[train_st.start() for train_st in train_thread_list]
if __name__ == '__main__':
create_thread()
Analysis:
The train signal thread starts first, printing "Red light on, all trains are waiting."
Enter 10 seconds of hibernation --- switch to the train departure thread 1 --- print "Train 1 waiting for departure signal" --- wait to the default value of False --- program blocking
Threads start in a very short time, before the end of hibernation before the train starts 9 threads start sequentially, running to wait are blocked
End of hibernation---Print "Green light is on, all trains are departing"---set set "Flag" of Event object to True---wait cancel blocking when it receives "Flag" changed from False to True---print "Trains are departing" sequentially.
6. Process and thread pools
6.1 Thread Pool
[1] Concept
Create a fixed number of threads ahead of time and use them repeatedly in subsequent sessions
If the task exceeds the maximum number of threads in the thread pool, wait for the
[2] The thread name will not exceed the value of the specified thread
import time
from import ThreadPoolExecutor
from threading import current_thread
pool = ThreadPoolExecutor(5) # The code runs and immediately spawns 5 threads waiting to work;Default: min(32, (() or 1) + 4)
def work():
(0.001) # Hibernation prevents threads from being started so fast that they all have the same thread name.
print(current_thread().name) # ThreadPoolExecutor-0_? will not have more than 5 names
for i in range(1, 10):
(work) # Adding Tasks to the Thread Pool
[3] Modalities for the submission of mandates
Synchronized submission:
After submitting a task, the master process waits for the task to complete before continuing to run subsequent code.
Asynchronous submission:
After submitting a task, the master process does not wait for the task to complete, but continues to run subsequent code.
The result of a task can be retrieved either through a callback function or when the result is needed.
This allows the master process to handle multiple tasks simultaneously.
[4] The thread pool submits tasks asynchronously
from import ThreadPoolExecutor
pool = ThreadPoolExecutor(5) # Immediately after the code runs, 5 threads are spawned waiting for work to be done
def work(num):
print(f'{num} start')
print(f'{num} end')
for i in range(1, 10):
(work, i) # Submitting tasks to the thread pool
Analysis:
No sleep between start and end, if it is a synchronous commit, the next task has to wait for the previous task to finish running before it can be started, analogous to serial to understand;
The above code without sleep between "start" and "end" is "chaotic", not "serial", so the way the thread pool submits tasks is asynchronous.
[5] The way to get the results returned by the task is to submit them synchronously
import time
from import ThreadPoolExecutor
pool = ThreadPoolExecutor(5) # Immediately after the code is run, 5 threads are spawned waiting for work to be done
def work(num):
print(f'{num} start')
(0.1)
print(f'{num} end')
return '---'
for i in range(1, 10):
res = (work, i) # Submitting tasks to the thread pool (asynchronous submission)
print(()) # The way to get the return result of a task is to submit it synchronously, and the default return value is None
Analysis:
The master process submits tasks to the thread pool asynchronously, and prints them in the same order as in step [4];
The print order changes to "serial" after the result( ) function is called, so the way to get the results from the task is to submit them synchronously.
[5] Asynchronous callbacks to get objects
In order to solve the problem of obtaining the result in step 4 as a synchronous submission instead of an asynchronous submission, which results in the main process not being able to process multiple tasks at the same time.
Using the asynchronous callback mechanism add_done_callback( ), the function in parentheses is automatically called whenever a task has a result to process
from import ThreadPoolExecutor
pool = ThreadPoolExecutor(5) # Immediately after the code is run, 5 threads are spawned waiting for work to be done
def work(num):
print(f'{num} start')
print(f'{num} end')
return '---'
def func(*args, **kwargs):
print(args, kwargs) # The value here is the same object as the result obtained by res=(work, i), and both can be called result() to get the return value of the thread function function
for i in range(1, 10):
(work, i).add_done_callback(func)
Analysis:
The value obtained by the asynchronous callback is the same object as the value of res=(work, i), which can both be called result( ) to get the return value of the thread function function;
Submitting tasks and getting results are asynchronous, allowing the master process to handle multiple tasks at the same time.
[6] Asynchronous callback to get the return value
Get the return value of the thread function function by index fetch + call function method on the basis of step 5
from import ThreadPoolExecutor
pool = ThreadPoolExecutor(5) # Immediately after the code is run, 5 threads are spawned waiting for work to be done
def work(num):
print(f'{num} start')
print(f'{num} end')
return '---'
def func(*args, **kwargs):
print(args[0].result())
for i in range(1, 10):
(work, i).add_done_callback(func)
[7] Usage of shutdown
The shutdown function is used to control the shutdown of the thread pool.
shutdown(wait=True)
The default value of wait is True, which waits for all threads to complete their running tasks before closing the thread pool.
wait, if set to False, immediately closes the thread pool, no longer receives new tasks, and does not wait for running tasks to complete
Usage is similar to join: the main process waits for the child thread to finish before terminating it
No shutdown.
from import ThreadPoolExecutor
pool = ThreadPoolExecutor(5) # Immediately after the code is run, 5 threads are spawned waiting for work to be done
def work(num):
print(f'{num} start')
print(f'{num} end')
if __name__ == '__main__':
print('Start of the main process')
for i in range(1, 10):
(work, i)
print('End of main process')
There's a shutdown.
from import ThreadPoolExecutor pool = ThreadPoolExecutor(5) # Immediately after the code is run, 5 threads are spawned waiting for work to be done def work(num): print(f'{num} start') print(f'{num} end') return '---' if __name__ == '__main__': print('Start of the main process') for i in range(1, 10): (work, i) () print('End of main process')
6.2 Process pools
[1] Concept
Create a fixed number of processes and use them over and over again.
No need for frequent creation of processes and frequent destruction of processes
[2] The number of process number ranges does not exceed the number of process numbers specified.
import os
import time
from import ProcessPoolExecutor
pool = ProcessPoolExecutor(5) # default os.cpu_count() or 1
def work(num):
(0.001) # sleep serves to switch processes, preventing processes from starting extremely fast and using the same process number
print(f'{num} start')
print(f'The process number is:{()}')
print(f'{num} end')
if __name__ == '__main__': # Unlike spawning threads, spawning processes are placed at the main program entrance
for i in range(1, 10):
(work, i)
Analysis:
The printout is still "disordered", not "serial", proving that the pool submits tasks asynchronously.
[3] Asynchronous callbacks to get objects
from import ProcessPoolExecutor
pool = ProcessPoolExecutor(5) # Default os.cpu_count() or 1
def work(num):
print(f'{num} start')
print(f'{num} end')
return '---'
def func(*args, **kwargs):
print(args, kwargs)
if __name__ == '__main__': # Unlike spawning a thread, spawning a process is placed at the main program entry.
for i in range(1, 10):
(work, i).add_done_callback(func)
Analysis:
Printing results in "disordered" order proves that submitting tasks and obtaining results are asynchronous; allowing the master process to handle multiple tasks at the same time.
[4] Asynchronous callback to get the return value
from import ProcessPoolExecutor
pool = ProcessPoolExecutor(5) # Default os.cpu_count() or 1
def work(num):
print(f'{num} start')
print(f'{num} end')
return '---'
def func(*args, **kwargs):
print(args[0].result())
if __name__ == '__main__': # Unlike spawning a thread, spawning a process is placed at the main program entry.
for i in range(1, 10):
(work, i).add_done_callback(func)