Normally, the asyncio.run() function is used to execute coroutines. The function provides a higher level way of executing a one-off coroutines. Typically, the function performs several low-level operation in the background. The function creates a new event loop, schedules the coroutine in the loop, and after the coroutine is done executing it closes the loop. 

import asyncio

async def task():
   await asyncio.sleep(0.01) #simulate a delay
   print('Hello, World!')

asyncio.run(task())

In the above example, we created a coroutine function i.e task(), then used the asyncio.run() function to execute the coroutine.

To run multiple coroutines, the asyncio.run()  function may be called more than once. However, it is worth noting that with each call, the process of creating a new event loop, scheduling  the task and closing the loop will be repeated for each coroutine. 

import asyncio

async def task1():
   await asyncio.sleep(0.01)
   print('Hello, World!')

async def task2():
   await asyncio.sleep(0.01)
   print('Goodbye, World!')

asyncio.run(task1())
asyncio.run(task2())

In the above example, we called the asyncio.run twice, thus two independent event loops are used for each call.

The asyncio.Runner class allows multiple tasks to be executed in the same event loop. Essentially, it allows an event loop to be re-used in execution of coroutines.

How asyncio.Runner works

The asyncio.Runner class allows multiple coroutines to be executed in the same event loop. The class was introduced in Python 3.11, in older versions we have to use the asyncio.run() function or other lower level approaches.

Instead of creating a new loop  for each coroutine, asyncio.Runner uses a single event loop with all the coroutines.
The run() method of the Runner objects is used to execute the coroutines with the same interface as the asyncio.run() function.

The following  example shows how to run a coroutine with asyncio.Runner.

import asyncio

import asyncio

async def task():
   await asyncio.sleep(0.01)
   print('Hello, World!')

runner = asyncio.Runner()

runner.run(task())

runner.close() #close the runner

In the above example we created a new Runner object  i.e runner = asyncio.Runner() . We then used the run() method  of the runner object to execute the task() coroutine, i.e runner.run(task()).

The last line i.e runner.close() is important, it tells the runner to close the event loop since we are done running the coroutines. Runner objects have a context manager interface, we can therefore avoid manually calling the runner.close() method by using it in a with statement. This way, the method is called automatically before the execution leaves the with block. The previous example will look as follows

import asyncio

import asyncio

async def task():
   await asyncio.sleep(0.01)
   print('Hello, World!')

with asyncio.Runner() as runner:
   runner.run(task())

#the runner is closed 

In the above example, the runner gets automatically closed before the execution leaves the with block, and there is therefore no need to call the runner.close() method.

To execute multiple coroutines, just call the runner.run() method multiple times with each coroutines.

import asyncio

import asyncio

async def task1():
   await asyncio.sleep(0.01)
   print('Hello, World!')

async def task2():
   await asyncio.sleep(0.01)
   print('Goodbye, World!')

with asyncio.Runner() as runner:
   runner.run(task1())
   runner.run(task2())

In the above example, we executed two coroutines i.e task1() and task2() in the same event loop. 

Enabling debug mode

If the debug parameter is set to True, the coroutines will be executed in debug mode.

import asyncio

async def task1():
   await asyncio.sleep(0.01)
   print('Hello, World!')

async def task2():
   await asyncio.sleep(0.01)
   print('Hello, World!')

with asyncio.Runner(debug = True) as runner:
   runner.run(task1())
   runner.run(task2())

Alternative approach - using awaits

An alternative approach for executing multiple tasks in the same event loop, is to create an extra coroutine, await the multiple coroutines in it, then run the extra coroutine using the asyncio.run() function. This can especially be useful when running an older Python version i.e below Python 3.11.

import asyncio

async def task1():
   await asyncio.sleep(0.01)
   print('Hello, World!')

async def task2():
   await asyncio.sleep(0.01)
   print('Hello, World!')

#the extra coroutine
async def main():
   await task1()
   await task2()

asyncio.run(main()) #run main with asyncio.run 

In the above example, we created a third coroutine i.e main() then awaited the other coroutines in it. The same results are achieved as using asyncio.Runner, this is because normally, the await expression ensures that the awaited coroutine is fully executed before moving on to the next.