<SYSTEM_TASK:>
Given the following text description, write Python code to implement the functionality described below step by step
<END_TASK>
<USER_TASK:>
Description:
 Using critical sections
A critical section is a region of code that should not run in parallel. For example, the increment of a variable is not considered an atomic operation, so, it should be performed using mutual exclusion.
What happens when mutial exclusion is not used in critical sections?
Using threads
All Python’s built-in data structures (such as lists, dictionaries, etc.) are thread-safe. However, other user's data structures implemented by users, or simpler types like integers and floats, should not be accesed concurrently.
Step1:  The same example, using mutual exclusion (using a lock)
Step2:  Notice that both tasks are CPU-bound. This means that using threading has not any wall time advantage compared to an iterative implementation of both taks.
Using processes
Step3:  Unlike threading, multiprocessing is suitable for reducing the running times in the case of CPU-bound problems.
Using coroutines
Like threads, coroutines should be only used when the coroutines must wait (typically for an I/O transaction). Otherwhise, use multiprocessing.
Step4:  Coroutines are faster than threads, but not faster than the one-loop version of the task.
<ASSISTANT_TASK:>
Python Code:
# Two threads that have a critical section executed in parallel without mutual exclusion.
# This code does not work!
import threading
import time
counter = 10
def task_1():
    global counter
    for i in range(10**6):
        counter += 1
        
def task_2():
    global counter
    for i in range(10**6+1):
        counter -= 1
        
thread_1 = threading.Thread(target=task_1)
thread_2 = threading.Thread(target=task_2)
thread_1.start()
thread_2.start()
print("(Both threads started)")
thread_1.join()
thread_2.join()
print("\nBoth threads finished")
print('counter =', counter)
Explanation: Using critical sections
A critical section is a region of code that should not run in parallel. For example, the increment of a variable is not considered an atomic operation, so, it should be performed using mutual exclusion.
What happens when mutial exclusion is not used in critical sections?
Using threads
All Python’s built-in data structures (such as lists, dictionaries, etc.) are thread-safe. However, other user's data structures implemented by users, or simpler types like integers and floats, should not be accesed concurrently.
End of explanation
# Two threads that have a critical section executed sequentially.
import threading
import time
lock = threading.Lock()
counter = 10
def task_1():
    global counter
    for i in range(10**6):
        with lock:
            counter += 1
        
def task_2():
    global counter
    for i in range(10**6+1):
        with lock:
            counter -= 1
        
thread_1 = threading.Thread(target=task_1)
thread_2 = threading.Thread(target=task_2)
now = time.perf_counter()  # Real time (not only user time)
thread_1.start()
thread_2.start()
print("Both threads started")
thread_1.join()
thread_2.join()
print("Both threads finished")
elapsed = time.perf_counter() - now
print(f"elapsed {elapsed:0.2f} seconds")
print('counter =', counter)
Explanation: The same example, using mutual exclusion (using a lock):
End of explanation
# Two processes that have a critical section executed sequentially
import multiprocessing
import time
import ctypes
def task_1(lock, counter):
    for i in range(10000):
        with lock:
            counter.value += 1
        
def task_2(lock, counter):
    for i in range(10001):
        with lock:
            counter.value -= 1
lock = multiprocessing.Lock()
manager = multiprocessing.Manager()
counter = manager.Value(ctypes.c_int, 10)
process_1 = multiprocessing.Process(target=task_1, args=(lock, counter))
process_2 = multiprocessing.Process(target=task_2, args=(lock, counter))
now = time.perf_counter()
process_1.start()
process_2.start()
print("Both tasks started")
process_1.join()
process_2.join()
print("Both tasks finished")
elapsed = time.perf_counter() - now
print(f"elapsed {elapsed:0.2f} seconds")
print('counter =', counter.value)
Explanation: Notice that both tasks are CPU-bound. This means that using threading has not any wall time advantage compared to an iterative implementation of both taks.
Using processes
End of explanation
import asyncio
counter = 10
async def task_1():
    global counter
    for i in range(10):
        print("o", end='', flush=True)
        counter += 1
        await task_2()
            
async def task_2():
    global counter
    print("O", end='', flush=True)
    counter -= 1
await task_1()
print('\ncounter =', counter)
import asyncio
import time
counter = 10
async def task_1():
    global counter
    for i in range(10**6):
        counter += 1
        await task_2()
        
async def task_2():
    global counter
    counter -= 1
now = time.perf_counter()
await task_1()
elapsed = time.perf_counter() - now
print(f"\nelapsed {elapsed:0.2f} seconds")
print('counter =', counter)
Explanation: Unlike threading, multiprocessing is suitable for reducing the running times in the case of CPU-bound problems.
Using coroutines
Like threads, coroutines should be only used when the coroutines must wait (typically for an I/O transaction). Otherwhise, use multiprocessing.
End of explanation
import time
counter = 10
def task():
    global counter
    for i in range(10**6):
        counter += 1
        counter -= 1
now = time.perf_counter()
task()
elapsed = time.perf_counter() - now
print(f"\nelapsed {elapsed:0.2f} seconds")
print('counter =', counter)
Explanation: Coroutines are faster than threads, but not faster than the one-loop version of the task.
End of explanation
<END_TASK>