---
jupytext:
  text_representation:
    extension: .md
    format_name: myst
    format_version: 0.13
    jupytext_version: 1.15.1
kernelspec:
  display_name: base
  language: python
  name: python3
---

# Simplified Concurrency
`labbench` includes simplified concurrency support for this kind of I/O-constrained operations like waiting for instruments to perform long operations. It is not suited for parallelizing CPU-intensive tasks because the operations share a single process on one CPU core, instead of multiprocessing, which may be able to spread operations across multiple CPU cores.

Here are simple Device objects that use {py:func}`time.sleep` as a stand-in for long-running remote operations:

```{code-cell} ipython3
import labbench as lb

# a placeholder for long-running remote operations
from time import sleep

class Device1(lb.VISADevice):
    def open(self):
        # open() is called on connection to the device
        with lb.stopwatch('Device1 connect'):
            sleep(1)
            
    def fetch(self):
        with lb.stopwatch('Device1 fetch'):
            sleep(1)
            return 5

class Device2(lb.VISADevice):
    def open(self):
        # open() is called on connection to the device
        with lb.stopwatch('Device2 connect'):
            sleep(2)
            
    def acquire(self):
        with lb.stopwatch('Device2 acquire'):
            sleep(2)
            return None
```

Suppose we need to both `fetch` from `Device1` and `acquire` in `Device2`, and that the time-sequencing is not important. One approach is to simply call one and then the other:

```{code-cell} ipython3
from labbench import testing
from time import perf_counter

# allow simulated connections to the specified VISA devices
lb.visa_default_resource_manager(testing.pyvisa_sim_resource)

d1 = Device1('TCPIP::localhost::INSTR')
d2 = Device2('USB::0x1111::0x2222::0x1234::INSTR')

t0 = perf_counter()
with d1, d2:
    print(f'connect both (total time): {perf_counter()-t0:0.1f} s')
    with lb.stopwatch('both Device1.fetch and Device2.acquire (total time)'):
        d1.fetch()
        d2.acquire()
```

For each of the connection and fetch/acquire operations, the total duration was about 3 seconds, because the 1 and 2 second operations are executed sequentially.

Suppose that we want to perform each of the open and fetch/acquire operations concurrently. Enter {py:func}`labbench.concurrently`:

```{code-cell} ipython3
from labbench import testing
from time import perf_counter

# allow simulated connections to the specified VISA devices
lb.visa_default_resource_manager(testing.pyvisa_sim_resource)

d1 = Device1('TCPIP::localhost::INSTR')
d2 = Device2('USB::0x1111::0x2222::0x1234::INSTR')

t0 = perf_counter()
with lb.concurrently(d1, d2):
    print(f'connect both (total time): {perf_counter()-t0:0.1f} s')
    with lb.stopwatch('both Device1.fetch and Device2.acquire (total time)'):
        ret = lb.concurrently(d1.fetch, d2.acquire)
        
print('Return value: ', ret)
```

Each call to {py:func}`labbench.concurrently` executes each callable in separate threads, and returns after the longest-running call.
* As a result, in this example, for each of the `open` and `fetch`/`acquire`, the total time is reduced from 3 s to 2 s.
* The return values of threaded calls are packaged into a dictionary for each call that does not return `None`.
The syntax is a little more involved when you want to pass in arguments to multiple callables. For information on doing this, see the [detailed instructions](../03_detailed_usage/05_concurrency).
