Skip to content

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

@veecore

Description

@veecore

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions