futures-rs icon indicating copy to clipboard operation
futures-rs copied to clipboard

`StreamExt::split` docs are misleading — it’s not truly concurrent and serializes reads/writes via BiLock

Open veecore opened this issue 1 month ago • 0 comments

Summary

The documentation for StreamExt::split currently implies that splitting enables concurrent use of a Stream + Sink object across tasks. However, the implementation uses a BiLock internally, which serializes access — meaning reads and writes do not actually occur concurrently.

This behavior is correct and safe, but the current docs can easily be misinterpreted as offering true full-duplex concurrency (similar to TcpStream::into_split).


Example of the confusion

A user might write code like this:

let (mut sink, mut stream) = framed.split();

tokio::spawn(async move {
    sink.send("ping").await.unwrap();
});

tokio::spawn(async move {
    while let Some(msg) = stream.next().await {
        println!("got: {:?}", msg);
    }
});

This compiles, but both halves share a BiLock, so internally:

  • Only one half can hold the lock at a time.
  • Reads and writes are serialized at the poll_* level.
  • The two tasks effectively take turns polling the same underlying resource.

So, while ownership is “split,” execution is not truly concurrent.


Why this matters

  • For transports like TcpStream, WebSockets, or any full-duplex I/O, this distinction is performance-significant. Developers often expect the split halves to operate independently, as in Tokio’s TcpStream split API.

  • For higher-level protocol abstractions, serialized polling can lead to unintuitive interleaving, where a read may occur between partial writes. This isn’t a safety issue, but it can surprise users who assume atomic or parallel I/O behavior.


Suggested fix

Add a short note section in the docstring clarifying that StreamExt::split is not concurrent and uses a BiLock internally. Something along these lines:

Note: This split does not enable true concurrent reading and writing. Both halves share internal state protected by a [BiLock], so reads and writes occur serially rather than in parallel. If you require full-duplex I/O, consider splitting the underlying I/O if it supports true concurrency

This would help set correct expectations without changing any behaviour.


Why this is safe to add

  • It doesn’t alter semantics or API surface.
  • It prevents common performance misunderstandings.
  • It helps explicitly distinguish logical vs physical concurrency.

References


Willing to contribute

I’d be happy to open a small PR to update the docs if maintainers agree that clarification would be helpful.

veecore avatar Nov 09 '25 00:11 veecore