Skip to content

Commit 05eeea5

Browse files
authored
coop: expose an unconstrained() opt-out (#3547)
1 parent f70b9b8 commit 05eeea5

5 files changed

Lines changed: 178 additions & 48 deletions

File tree

tokio/src/coop.rs

Lines changed: 33 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,33 @@
11
#![cfg_attr(not(feature = "full"), allow(dead_code))]
22

3-
//! Opt-in yield points for improved cooperative scheduling.
3+
//! Yield points for improved cooperative scheduling.
44
//!
5-
//! A single call to [`poll`] on a top-level task may potentially do a lot of
6-
//! work before it returns `Poll::Pending`. If a task runs for a long period of
7-
//! time without yielding back to the executor, it can starve other tasks
8-
//! waiting on that executor to execute them, or drive underlying resources.
9-
//! Since Rust does not have a runtime, it is difficult to forcibly preempt a
10-
//! long-running task. Instead, this module provides an opt-in mechanism for
11-
//! futures to collaborate with the executor to avoid starvation.
5+
//! Documentation for this can be found in the [`tokio::task`] module.
126
//!
13-
//! Consider a future like this one:
14-
//!
15-
//! ```
16-
//! # use tokio_stream::{Stream, StreamExt};
17-
//! async fn drop_all<I: Stream + Unpin>(mut input: I) {
18-
//! while let Some(_) = input.next().await {}
19-
//! }
20-
//! ```
21-
//!
22-
//! It may look harmless, but consider what happens under heavy load if the
23-
//! input stream is _always_ ready. If we spawn `drop_all`, the task will never
24-
//! yield, and will starve other tasks and resources on the same executor. With
25-
//! opt-in yield points, this problem is alleviated:
26-
//!
27-
//! ```ignore
28-
//! # use tokio_stream::{Stream, StreamExt};
29-
//! async fn drop_all<I: Stream + Unpin>(mut input: I) {
30-
//! while let Some(_) = input.next().await {
31-
//! tokio::coop::proceed().await;
32-
//! }
33-
//! }
34-
//! ```
35-
//!
36-
//! The `proceed` future will coordinate with the executor to make sure that
37-
//! every so often control is yielded back to the executor so it can run other
38-
//! tasks.
39-
//!
40-
//! # Placing yield points
41-
//!
42-
//! Voluntary yield points should be placed _after_ at least some work has been
43-
//! done. If they are not, a future sufficiently deep in the task hierarchy may
44-
//! end up _never_ getting to run because of the number of yield points that
45-
//! inevitably appear before it is reached. In general, you will want yield
46-
//! points to only appear in "leaf" futures -- those that do not themselves poll
47-
//! other futures. By doing this, you avoid double-counting each iteration of
48-
//! the outer future against the cooperating budget.
49-
//!
50-
//! [`poll`]: method@std::future::Future::poll
51-
52-
// NOTE: The doctests in this module are ignored since the whole module is (currently) private.
7+
//! [`tokio::task`]: crate::task.
8+
9+
// ```ignore
10+
// # use tokio_stream::{Stream, StreamExt};
11+
// async fn drop_all<I: Stream + Unpin>(mut input: I) {
12+
// while let Some(_) = input.next().await {
13+
// tokio::coop::proceed().await;
14+
// }
15+
// }
16+
// ```
17+
//
18+
// The `proceed` future will coordinate with the executor to make sure that
19+
// every so often control is yielded back to the executor so it can run other
20+
// tasks.
21+
//
22+
// # Placing yield points
23+
//
24+
// Voluntary yield points should be placed _after_ at least some work has been
25+
// done. If they are not, a future sufficiently deep in the task hierarchy may
26+
// end up _never_ getting to run because of the number of yield points that
27+
// inevitably appear before it is reached. In general, you will want yield
28+
// points to only appear in "leaf" futures -- those that do not themselves poll
29+
// other futures. By doing this, you avoid double-counting each iteration of
30+
// the outer future against the cooperating budget.
5331

5432
use std::cell::Cell;
5533

@@ -98,6 +76,13 @@ pub(crate) fn budget<R>(f: impl FnOnce() -> R) -> R {
9876
with_budget(Budget::initial(), f)
9977
}
10078

79+
/// Run the given closure with an unconstrained task budget. When the function returns, the budget
80+
/// is reset to the value prior to calling the function.
81+
#[inline(always)]
82+
pub(crate) fn with_unconstrained<R>(f: impl FnOnce() -> R) -> R {
83+
with_budget(Budget::unconstrained(), f)
84+
}
85+
10186
#[inline(always)]
10287
fn with_budget<R>(budget: Budget, f: impl FnOnce() -> R) -> R {
10388
struct ResetGuard<'a> {

tokio/src/macros/cfg.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,3 +357,21 @@ macro_rules! cfg_coop {
357357
)*
358358
}
359359
}
360+
361+
macro_rules! cfg_not_coop {
362+
($($item:item)*) => {
363+
$(
364+
#[cfg(not(any(
365+
feature = "fs",
366+
feature = "io-std",
367+
feature = "net",
368+
feature = "process",
369+
feature = "rt",
370+
feature = "signal",
371+
feature = "sync",
372+
feature = "time",
373+
)))]
374+
$item
375+
)*
376+
}
377+
}

tokio/src/task/mod.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,11 +209,66 @@
209209
//! # }
210210
//! ```
211211
//!
212+
//! ### Cooperative scheduling
213+
//!
214+
//! A single call to [`poll`] on a top-level task may potentially do a lot of
215+
//! work before it returns `Poll::Pending`. If a task runs for a long period of
216+
//! time without yielding back to the executor, it can starve other tasks
217+
//! waiting on that executor to execute them, or drive underlying resources.
218+
//! Since Rust does not have a runtime, it is difficult to forcibly preempt a
219+
//! long-running task. Instead, this module provides an opt-in mechanism for
220+
//! futures to collaborate with the executor to avoid starvation.
221+
//!
222+
//! Consider a future like this one:
223+
//!
224+
//! ```
225+
//! # use tokio_stream::{Stream, StreamExt};
226+
//! async fn drop_all<I: Stream + Unpin>(mut input: I) {
227+
//! while let Some(_) = input.next().await {}
228+
//! }
229+
//! ```
230+
//!
231+
//! It may look harmless, but consider what happens under heavy load if the
232+
//! input stream is _always_ ready. If we spawn `drop_all`, the task will never
233+
//! yield, and will starve other tasks and resources on the same executor.
234+
//!
235+
//! To account for this, Tokio has explicit yield points in a number of library
236+
//! functions, which force tasks to return to the executor periodically.
237+
//!
238+
//!
239+
//! #### unconstrained
240+
//!
241+
//! If necessary, [`task::unconstrained`] lets you opt out a future of Tokio's cooperative
242+
//! scheduling. When a future is wrapped with `unconstrained`, it will never be forced to yield to
243+
//! Tokio. For example:
244+
//!
245+
//! ```
246+
//! # #[tokio::main]
247+
//! # async fn main() {
248+
//! use tokio::{task, sync::mpsc};
249+
//!
250+
//! let fut = async {
251+
//! let (tx, mut rx) = mpsc::unbounded_channel();
252+
//!
253+
//! for i in 0..1000 {
254+
//! let _ = tx.send(());
255+
//! // This will always be ready. If coop was in effect, this code would be forced to yield
256+
//! // periodically. However, if left unconstrained, then this code will never yield.
257+
//! rx.recv().await;
258+
//! }
259+
//! };
260+
//!
261+
//! task::unconstrained(fut).await;
262+
//! # }
263+
//! ```
264+
//!
212265
//! [`task::spawn_blocking`]: crate::task::spawn_blocking
213266
//! [`task::block_in_place`]: crate::task::block_in_place
214267
//! [rt-multi-thread]: ../runtime/index.html#threaded-scheduler
215268
//! [`task::yield_now`]: crate::task::yield_now()
216269
//! [`thread::yield_now`]: std::thread::yield_now
270+
//! [`task::unconstrained`]: crate::task::unconstrained()
271+
//! [`poll`]: method@std::future::Future::poll
217272
218273
cfg_rt! {
219274
pub use crate::runtime::task::{JoinError, JoinHandle};
@@ -236,4 +291,7 @@ cfg_rt! {
236291

237292
mod task_local;
238293
pub use task_local::LocalKey;
294+
295+
mod unconstrained;
296+
pub use unconstrained::{unconstrained, Unconstrained};
239297
}

tokio/src/task/unconstrained.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
use pin_project_lite::pin_project;
2+
use std::future::Future;
3+
use std::pin::Pin;
4+
use std::task::{Context, Poll};
5+
6+
pin_project! {
7+
/// Future for the [`unconstrained`](unconstrained) method.
8+
#[must_use = "Unconstrained does nothing unless polled"]
9+
pub struct Unconstrained<F> {
10+
#[pin]
11+
inner: F,
12+
}
13+
}
14+
15+
impl<F> Future for Unconstrained<F>
16+
where
17+
F: Future,
18+
{
19+
type Output = <F as Future>::Output;
20+
21+
cfg_coop! {
22+
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
23+
let inner = self.project().inner;
24+
crate::coop::with_unconstrained(|| inner.poll(cx))
25+
}
26+
}
27+
28+
cfg_not_coop! {
29+
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
30+
let inner = self.project().inner;
31+
inner.poll(cx)
32+
}
33+
}
34+
}
35+
36+
/// Turn off cooperative scheduling for a future. The future will never be forced to yield by
37+
/// Tokio. Using this exposes your service to starvation if the unconstrained future never yields
38+
/// otherwise.
39+
///
40+
/// See also the usage example in the [task module](index.html#unconstrained).
41+
pub fn unconstrained<F>(inner: F) -> Unconstrained<F> {
42+
Unconstrained { inner }
43+
}

tokio/tests/rt_common.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,6 +1017,32 @@ rt_test! {
10171017
});
10181018
}
10191019

1020+
#[test]
1021+
fn coop_unconstrained() {
1022+
use std::task::Poll::Ready;
1023+
1024+
let rt = rt();
1025+
1026+
rt.block_on(async {
1027+
// Create a bunch of tasks
1028+
let mut tasks = (0..1_000).map(|_| {
1029+
tokio::spawn(async { })
1030+
}).collect::<Vec<_>>();
1031+
1032+
// Hope that all the tasks complete...
1033+
time::sleep(Duration::from_millis(100)).await;
1034+
1035+
tokio::task::unconstrained(poll_fn(|cx| {
1036+
// All the tasks should be ready
1037+
for task in &mut tasks {
1038+
assert!(Pin::new(task).poll(cx).is_ready());
1039+
}
1040+
1041+
Ready(())
1042+
})).await;
1043+
});
1044+
}
1045+
10201046
// Tests that the "next task" scheduler optimization is not able to starve
10211047
// other tasks.
10221048
#[test]

0 commit comments

Comments
 (0)