Repository: pyodide/pyodide

Issue:
Better performing event loop
## Proposed refactoring or deprecation

The current implementation in [webloop.py](https://github.com/pyodide/pyodide/blob/3caa249177c09624b4e2039d4a68cfcf2fb014c4/src/py/pyodide/webloop.py#L342) relies on javascript's setTimeout function. While this implementation works, it introduces an issue for delays < 4ms, because per standard in all browsers `setTimeout` supports a minimum delay value of 4ms.    
On occasions developers introduce `await asyncio.sleep(0)` in their code to relinquish execution for a brief period in time in parts of asynchronous functions which would be called repetitively thus hogging the event loop.  
Since pyodide uses setTimeout, each call to `await asyncio.sleep(0)` will introduce a 4ms delay which in turn makes the code appear slow.

### Motivation
Aardwolf RDP client implementation is running slower than expected on pyodide because of this issue.

### Pitch
I'd like to have a replacement implemented in pyodide's `webloop.py` for `setTimeout` which supports faster callbacks.

### Additional context

I did some tests which did speed up my code by replacing calls for setTimeout with a MessageChannel-based implementation if `delay==0`. It worked on the live tests, here are the results:

- default behaviour
```
import time
import asyncio


>>> async def measure_execution_time():
...  num_measurements = 1000
...  total_time = 0
...  for _ in range(num_measurements):
...   start_time = time.perf_counter()
...   await asyncio.sleep(0)
...   end_time = time.perf_counter()
...   total_time += (end_time - start_time)
...  average_time = total_time / num_measurements
...  print(f"On average, the function took {average_time} seconds to execute")
... 

>>> await measure_execution_time()
On average, the function took 0.00456210000899992 seconds to execute
>>> await measure_execution_time()
On average, the function took 0.004544299998000042 seconds to execute
```
- modifying `webloop.py` to use `zeroTimeout`:
```
function zeroTimeout(callback, delay) {
    if(delay == 0){
        const channel = new MessageChannel();
        channel.port1.onmessage = callback;
        channel.port2.postMessage('');
    }
    else{
        setTimeout(callback, delay);
    }  
}
```
```
Python 3.11.2 (main, Jul 21 2023 11:59:42) on WebAssembly/Emscripten
Type "help", "copyright", "credits" or "license" for more information.
>>> import asyncio
>>> import time
>>> async def measure_execution_time():
...  num_measurements = 1000
...  total_time = 0
...  for _ in range(num_measurements):
...   start_time = time.perf_counter()
...   await asyncio.sleep(0)
...   end_time = time.perf_counter()
...   total_time += (end_time - start_time)
...  average_time = total_time / num_measurements
...  print(f"On average, the function took {average_time} seconds to execute")
... 
>>> await measure_execution_time()
On average, the function took 0.00016000000100000023 seconds to execute
>>> await measure_execution_time()
On average, the function took 0.00016209999599999493 seconds to execute
>>> await measure_execution_time()
On average, the function took 0.00014569999600001893 seconds to execute
```
The `webloop.py` script was modified in the following way:
```
if IN_BROWSER:
    from js import setTimeout as originalSetTimeout

    try:
        from js import zeroTimeout as setTimeout
    except ImportError:
        setTimeout = originalSetTimeout
        print('Failed to import zeroTimeout')
```
```
 def run_handle(*args, **kwargs):
```
The latter modification was needed because the `MessageChannel` version must dispatch some data to `run_handle`.  
  
*WARNING*   
I have no idea if this messes up something on the long run!!!!


Discussion:
Not sure if related or not (as it's on using regular Emscripten/WASM in Chrome without pyodide), but I noticed that even mild file manipulation of Emscripten's `Module.FS` on the main UI thread (even if using async) slows Chrome UI rendering considerably (seems no simple way to ensure regular enough "ui events handling" when some background, even async procesing is happening). So far my sentiment is that for almost any work we must use Web Workers to avoid any UI freezes...
Thanks @skelsec. I wonder if we could use something like:
```js
if (delay_ms < 4) {
  await 0;
} else {
  await sleep(delay_ms);
}
```
I have some code that uses asyncio.sleep(0) and runs a lot slower in Pyodide than I would expect. Is there an easy way to test this with a PyScript XWorker or a pyodide WebWorker? 
@e-nikolov

I think you can open a PR with the proposed fixes here. Personally, I am okay with @skelsec's fix, so unless other Pyodide devs are against it, we can merge. One thing we need to check is that `MessageChannel` support in Node (it looks like Node < 15 partially support `MessageChannel`?)



Resolving patch:
diff --git a/src/py/pyodide/webloop.py b/src/py/pyodide/webloop.py
--- a/src/py/pyodide/webloop.py
+++ b/src/py/pyodide/webloop.py
@@ -11,7 +11,7 @@
 from .ffi import IN_BROWSER, create_once_callable
 
 if IN_BROWSER:
-    from js import setTimeout
+    from pyodide_js._api import scheduleCallback
 
 T = TypeVar("T")
 S = TypeVar("S")
@@ -343,7 +343,10 @@ def run_handle():
                 else:
                     raise
 
-        setTimeout(create_once_callable(run_handle, _may_syncify=True), delay * 1000)
+        scheduleCallback(
+            create_once_callable(run_handle, _may_syncify=True), delay * 1000
+        )
+
         return h
 
     def _decrement_in_progress(self, *args):


Test patch:
diff --git a/src/js/test/unit/scheduler.test.ts b/src/js/test/unit/scheduler.test.ts
new file mode 100644
--- /dev/null
+++ b/src/js/test/unit/scheduler.test.ts
@@ -0,0 +1,21 @@
+import * as chai from "chai";
+import { scheduleCallback } from "../../scheduler";
+
+describe("scheduleCallback", () => {
+  // Note: This test requires `--exit` flag to be set for mocha
+  //       to avoid hanging the process
+  //       see: https://github.com/facebook/react/issues/26608
+  it("should call the callback immediately if timeout is 0", () => {
+    const start = Date.now();
+    scheduleCallback(() => {
+      chai.assert.isAtMost(Date.now() - start, 4);
+    });
+  });
+
+  it("should call the callback after the given timeout", () => {
+    const start = Date.now();
+    scheduleCallback(() => {
+      chai.assert.isAtLeast(Date.now() - start, 10);
+    }, 10);
+  });
+});
diff --git a/src/tests/test_webloop.py b/src/tests/test_webloop.py
--- a/src/tests/test_webloop.py
+++ b/src/tests/test_webloop.py
@@ -497,3 +497,20 @@ async def temp():
         loop._no_in_progress_handler = None
         loop._keyboard_interrupt_handler = None
         loop._system_exit_handler = None
+
+
+@run_in_pyodide
+async def test_zero_timeout(selenium):
+    import asyncio
+    import time
+
+    now = time.time()
+
+    for _ in range(1000):
+        await asyncio.sleep(0)
+
+    done = time.time()
+    elapsed = done - now
+
+    # Very rough check, we hope it's less than 4s (1000 * 4ms [setTimeout delay in most browsers])
+    assert elapsed < 4, f"elapsed: {elapsed}s"