Or perhaps you're using NodeJS-style async callbacks. Guess what happens... Your async read looks like it's just using an event queue, but behind the scenes, NodeJS calls libuv which creates a thread which blocks in read(), and when read() unblocks and returns your callback is added to the event queue.
Perhaps you're using the async/await style with the Promise API for truly async goodness. Guess what happens... Your async call still ends up calling libuv which creates a thread which blocks in read(), and your await waits until libuv's thread read() unblocks and tells the Promise that it's fulfilled.
Maybe you're using shiny io_uring() - finally (it was proposed many times before) a real async system call interface in Linux! Guess what happens... Your lovely async/await calls the pristine goodness of io_uring_enter() which... creates a kernel thread which blocks in read(), and your await waits until the kernel thread's read() unblocks and reports a result.
Even with async/await languages and frameworks, and event-driven frameworks, a blocking thread is used somewhere in the stack to read from a file.
I've measured how many threads to create for optimal throughput of small reads from NVMe storage, and the fastest number of threads varies but it's generally in the tens to low hundreds, and always more than the number of CPU cores, if the data is not already in cache.
This is a complex area to tune adaptively, and Linux itself is unfortunately not ideal for this (it doesn't tell the userspace scheduler about blocked vs preempted I/O threads). Interestingly, reducing the number of CPU cores (using taskset) while keeping the same number of threads sometimes increases I/O throughput.
(Note: The above assumes Linux, not O_DIRECT with libaio, and that data is not in cache when using io_uring).