Skip to content

Commit 2e567d6

Browse files
authored
feat: add semaphore for blocking IO requests (#20289)
1 parent 28e7c8a commit 2e567d6

File tree

13 files changed

+224
-11
lines changed

13 files changed

+224
-11
lines changed

crates/node/builder/src/rpc.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1179,6 +1179,7 @@ impl<'a, N: FullNodeComponents<Types: NodeTypes<ChainSpec: Hardforks + EthereumH
11791179
.proof_permits(self.config.proof_permits)
11801180
.gas_oracle_config(self.config.gas_oracle)
11811181
.max_batch_size(self.config.max_batch_size)
1182+
.max_blocking_io_requests(self.config.max_blocking_io_requests)
11821183
.pending_block_kind(self.config.pending_block_kind)
11831184
.raw_tx_forwarder(self.config.raw_tx_forwarder)
11841185
.evm_memory_limit(self.config.rpc_evm_memory_limit)

crates/node/core/src/args/rpc_server.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ pub(crate) const RPC_DEFAULT_MAX_REQUEST_SIZE_MB: u32 = 15;
3737
pub(crate) const RPC_DEFAULT_MAX_RESPONSE_SIZE_MB: u32 = 160;
3838

3939
/// Default number of incoming connections.
40+
///
41+
/// This restricts how many active connections (http, ws) the server accepts.
42+
/// Once exceeded, the server can reject new connections.
4043
pub(crate) const RPC_DEFAULT_MAX_CONNECTIONS: u32 = 500;
4144

4245
/// Parameters for configuring the rpc more granularity via CLI
@@ -166,6 +169,14 @@ pub struct RpcServerArgs {
166169
#[arg(long = "rpc.max-tracing-requests", alias = "rpc-max-tracing-requests", value_name = "COUNT", default_value_t = constants::default_max_tracing_requests())]
167170
pub rpc_max_tracing_requests: usize,
168171

172+
/// Maximum number of concurrent blocking IO requests.
173+
///
174+
/// Blocking IO requests include `eth_call`, `eth_estimateGas`, and similar methods that
175+
/// require EVM execution. These are spawned as blocking tasks to avoid blocking the async
176+
/// runtime.
177+
#[arg(long = "rpc.max-blocking-io-requests", alias = "rpc-max-blocking-io-requests", value_name = "COUNT", default_value_t = constants::DEFAULT_MAX_BLOCKING_IO_REQUEST)]
178+
pub rpc_max_blocking_io_requests: usize,
179+
169180
/// Maximum number of blocks for `trace_filter` requests.
170181
#[arg(long = "rpc.max-trace-filter-blocks", alias = "rpc-max-trace-filter-blocks", value_name = "COUNT", default_value_t = constants::DEFAULT_MAX_TRACE_FILTER_BLOCKS)]
171182
pub rpc_max_trace_filter_blocks: u64,
@@ -414,6 +425,7 @@ impl Default for RpcServerArgs {
414425
rpc_max_subscriptions_per_connection: RPC_DEFAULT_MAX_SUBS_PER_CONN.into(),
415426
rpc_max_connections: RPC_DEFAULT_MAX_CONNECTIONS.into(),
416427
rpc_max_tracing_requests: constants::default_max_tracing_requests(),
428+
rpc_max_blocking_io_requests: constants::DEFAULT_MAX_BLOCKING_IO_REQUEST,
417429
rpc_max_trace_filter_blocks: constants::DEFAULT_MAX_TRACE_FILTER_BLOCKS,
418430
rpc_max_blocks_per_filter: constants::DEFAULT_MAX_BLOCKS_PER_FILTER.into(),
419431
rpc_max_logs_per_response: (constants::DEFAULT_MAX_LOGS_PER_RESPONSE as u64).into(),

crates/optimism/rpc/src/eth/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,11 @@ where
272272
fn tracing_task_guard(&self) -> &BlockingTaskGuard {
273273
self.inner.eth_api.blocking_task_guard()
274274
}
275+
276+
#[inline]
277+
fn blocking_io_task_guard(&self) -> &Arc<tokio::sync::Semaphore> {
278+
self.inner.eth_api.blocking_io_request_semaphore()
279+
}
275280
}
276281

277282
impl<N, Rpc> LoadFee for OpEthApi<N, Rpc>

crates/rpc/rpc-builder/src/config.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ impl RethRpcServerConfig for RpcServerArgs {
9494
fn eth_config(&self) -> EthConfig {
9595
EthConfig::default()
9696
.max_tracing_requests(self.rpc_max_tracing_requests)
97+
.max_blocking_io_requests(self.rpc_max_blocking_io_requests)
9798
.max_trace_filter_blocks(self.rpc_max_trace_filter_blocks)
9899
.max_blocks_per_filter(self.rpc_max_blocks_per_filter.unwrap_or_max())
99100
.max_logs_per_response(self.rpc_max_logs_per_response.unwrap_or_max() as usize)

crates/rpc/rpc-eth-api/src/helpers/blocking_task.rs

Lines changed: 116 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,40 +7,151 @@ use reth_tasks::{
77
pool::{BlockingTaskGuard, BlockingTaskPool},
88
TaskSpawner,
99
};
10-
use tokio::sync::{oneshot, AcquireError, OwnedSemaphorePermit};
10+
use std::sync::Arc;
11+
use tokio::sync::{oneshot, AcquireError, OwnedSemaphorePermit, Semaphore};
1112

1213
use crate::EthApiTypes;
1314

14-
/// Executes code on a blocking thread.
15+
/// Helpers for spawning blocking operations.
16+
///
17+
/// Operations can be blocking because they require lots of CPU work and/or IO.
18+
///
19+
/// This differentiates between workloads that are primarily CPU bound and heavier in general (such
20+
/// as tracing tasks) and tasks that have a more balanced profile (io and cpu), such as `eth_call`
21+
/// and alike.
22+
///
23+
/// This provides access to semaphores that permit how many of those are permitted concurrently.
24+
/// It's expected that tracing related tasks are configured with a lower threshold, because not only
25+
/// are they CPU heavy but they can also accumulate more memory for the traces.
1526
pub trait SpawnBlocking: EthApiTypes + Clone + Send + Sync + 'static {
1627
/// Returns a handle for spawning IO heavy blocking tasks.
1728
///
1829
/// Runtime access in default trait method implementations.
1930
fn io_task_spawner(&self) -> impl TaskSpawner;
2031

21-
/// Returns a handle for spawning CPU heavy blocking tasks.
32+
/// Returns a handle for spawning __CPU heavy__ blocking tasks, such as tracing requests.
2233
///
2334
/// Thread pool access in default trait method implementations.
2435
fn tracing_task_pool(&self) -> &BlockingTaskPool;
2536

2637
/// Returns handle to semaphore for pool of CPU heavy blocking tasks.
2738
fn tracing_task_guard(&self) -> &BlockingTaskGuard;
2839

40+
/// Returns handle to semaphore for blocking IO tasks.
41+
///
42+
/// This semaphore is used to limit concurrent blocking IO operations like `eth_call`,
43+
/// `eth_estimateGas`, and similar methods that require EVM execution.
44+
fn blocking_io_task_guard(&self) -> &Arc<Semaphore>;
45+
46+
/// Acquires a permit from the tracing task semaphore.
47+
///
48+
/// This should be used for __CPU heavy__ operations like `debug_traceTransaction`,
49+
/// `debug_traceCall`, and similar tracing methods. These tasks are typically:
50+
/// - Primarily CPU bound with intensive computation
51+
/// - Can accumulate significant memory for trace results
52+
/// - Expected to have lower concurrency limits than general blocking IO tasks
53+
///
54+
/// For blocking IO tasks like `eth_call` or `eth_estimateGas`, use
55+
/// [`acquire_owned_blocking_io`](Self::acquire_owned_blocking_io) instead.
56+
///
2957
/// See also [`Semaphore::acquire_owned`](`tokio::sync::Semaphore::acquire_owned`).
30-
fn acquire_owned(
58+
fn acquire_owned_tracing(
3159
&self,
3260
) -> impl Future<Output = Result<OwnedSemaphorePermit, AcquireError>> + Send {
3361
self.tracing_task_guard().clone().acquire_owned()
3462
}
3563

64+
/// Acquires multiple permits from the tracing task semaphore.
65+
///
66+
/// This should be used for particularly heavy tracing operations that require more resources
67+
/// than a standard trace. The permit count should reflect the expected resource consumption
68+
/// relative to a standard tracing operation.
69+
///
70+
/// Like [`acquire_owned_tracing`](Self::acquire_owned_tracing), this is specifically for
71+
/// CPU-intensive tracing tasks, not general blocking IO operations.
72+
///
3673
/// See also [`Semaphore::acquire_many_owned`](`tokio::sync::Semaphore::acquire_many_owned`).
37-
fn acquire_many_owned(
74+
fn acquire_many_owned_tracing(
3875
&self,
3976
n: u32,
4077
) -> impl Future<Output = Result<OwnedSemaphorePermit, AcquireError>> + Send {
4178
self.tracing_task_guard().clone().acquire_many_owned(n)
4279
}
4380

81+
/// Acquires a permit from the blocking IO request semaphore.
82+
///
83+
/// This should be used for operations like `eth_call`, `eth_estimateGas`, and similar methods
84+
/// that require EVM execution and are spawned as blocking tasks.
85+
///
86+
/// See also [`Semaphore::acquire_owned`](`tokio::sync::Semaphore::acquire_owned`).
87+
fn acquire_owned_blocking_io(
88+
&self,
89+
) -> impl Future<Output = Result<OwnedSemaphorePermit, AcquireError>> + Send {
90+
self.blocking_io_task_guard().clone().acquire_owned()
91+
}
92+
93+
/// Acquires multiple permits from the blocking IO request semaphore.
94+
///
95+
/// This should be used for operations that may require more resources than a single permit
96+
/// allows.
97+
///
98+
/// See also [`Semaphore::acquire_many_owned`](`tokio::sync::Semaphore::acquire_many_owned`).
99+
fn acquire_many_owned_blocking_io(
100+
&self,
101+
n: u32,
102+
) -> impl Future<Output = Result<OwnedSemaphorePermit, AcquireError>> + Send {
103+
self.blocking_io_task_guard().clone().acquire_many_owned(n)
104+
}
105+
106+
/// Acquires permits from the blocking IO request semaphore based on a calculated weight.
107+
///
108+
/// The weight determines the maximum number of concurrent requests of this type that can run.
109+
/// For example, if the semaphore has 256 total permits and `weight=10`, then at most 10
110+
/// concurrent requests of this type are allowed.
111+
///
112+
/// The permits acquired per request is calculated as `total_permits / weight`, with an
113+
/// adjustment: if this result is even, we add 1 to ensure that `weight - 1` permits are
114+
/// always available for other tasks, preventing complete semaphore exhaustion.
115+
///
116+
/// This should be used to explicitly limit concurrent requests based on their expected
117+
/// resource consumption:
118+
///
119+
/// - **Block range queries**: Higher weight for larger ranges (fewer concurrent requests)
120+
/// - **Complex calls**: Higher weight for expensive operations
121+
/// - **Batch operations**: Higher weight for larger batches
122+
/// - **Historical queries**: Higher weight for deeper history lookups
123+
///
124+
/// # Examples
125+
///
126+
/// ```ignore
127+
/// // For a heavy request, use higher weight to limit concurrency
128+
/// let weight = 20; // Allow at most 20 concurrent requests of this type
129+
/// let _permit = self.acquire_weighted_blocking_io(weight).await?;
130+
/// ```
131+
///
132+
/// This helps prevent resource exhaustion from concurrent expensive operations while allowing
133+
/// many cheap operations to run in parallel.
134+
///
135+
/// See also [`Semaphore::acquire_many_owned`](`tokio::sync::Semaphore::acquire_many_owned`).
136+
fn acquire_weighted_blocking_io(
137+
&self,
138+
weight: u32,
139+
) -> impl Future<Output = Result<OwnedSemaphorePermit, AcquireError>> + Send {
140+
let guard = self.blocking_io_task_guard();
141+
let total_permits = guard.available_permits().max(1) as u32;
142+
let weight = weight.max(1);
143+
let mut permits_to_acquire = (total_permits / weight).max(1);
144+
145+
// If total_permits divides evenly by weight, add 1 to ensure that when `weight`
146+
// concurrent requests are running, at least `weight - 1` permits remain available
147+
// for other tasks
148+
if total_permits.is_multiple_of(weight) {
149+
permits_to_acquire += 1;
150+
}
151+
152+
guard.clone().acquire_many_owned(permits_to_acquire)
153+
}
154+
44155
/// Executes the future on a new blocking task.
45156
///
46157
/// Note: This is expected for futures that are dominated by blocking IO operations, for tracing

crates/rpc/rpc-eth-api/src/helpers/call.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ pub trait EthCall: EstimateCall + Call + LoadPendingBlock + LoadBlock + FullEthA
212212
overrides: EvmOverrides,
213213
) -> impl Future<Output = Result<Bytes, Self::Error>> + Send {
214214
async move {
215+
let _permit = self.acquire_owned_blocking_io().await;
215216
let res =
216217
self.transact_call_at(request, block_number.unwrap_or_default(), overrides).await?;
217218

crates/rpc/rpc-eth-api/src/helpers/state.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ pub trait EthState: LoadState + SpawnBlocking {
9797
{
9898
Ok(async move {
9999
let _permit = self
100-
.acquire_owned()
100+
.acquire_owned_tracing()
101101
.await
102102
.map_err(RethError::other)
103103
.map_err(EthApiError::Internal)?;

crates/rpc/rpc-eth-types/src/builder/config.rs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ use crate::{
88
};
99
use reqwest::Url;
1010
use reth_rpc_server_types::constants::{
11-
default_max_tracing_requests, DEFAULT_ETH_PROOF_WINDOW, DEFAULT_MAX_BLOCKS_PER_FILTER,
12-
DEFAULT_MAX_LOGS_PER_RESPONSE, DEFAULT_MAX_SIMULATE_BLOCKS, DEFAULT_MAX_TRACE_FILTER_BLOCKS,
13-
DEFAULT_PROOF_PERMITS, RPC_DEFAULT_SEND_RAW_TX_SYNC_TIMEOUT_SECS,
11+
default_max_tracing_requests, DEFAULT_ETH_PROOF_WINDOW, DEFAULT_MAX_BLOCKING_IO_REQUEST,
12+
DEFAULT_MAX_BLOCKS_PER_FILTER, DEFAULT_MAX_LOGS_PER_RESPONSE, DEFAULT_MAX_SIMULATE_BLOCKS,
13+
DEFAULT_MAX_TRACE_FILTER_BLOCKS, DEFAULT_PROOF_PERMITS,
14+
RPC_DEFAULT_SEND_RAW_TX_SYNC_TIMEOUT_SECS,
1415
};
1516
use serde::{Deserialize, Serialize};
1617

@@ -68,6 +69,15 @@ pub struct EthConfig {
6869
pub eth_proof_window: u64,
6970
/// The maximum number of tracing calls that can be executed in concurrently.
7071
pub max_tracing_requests: usize,
72+
/// The maximum number of blocking IO calls that can be executed in concurrently.
73+
///
74+
/// Requests such as `eth_call`, `eth_estimateGas` and alike require evm execution, which is
75+
/// considered blocking since it's usually more heavy on the IO side but also CPU constrained.
76+
/// It is expected that these are spawned as short lived blocking tokio tasks. This config
77+
/// determines how many can be spawned concurrently, to avoid a build up in the tokio's
78+
/// blocking pool queue since there's only a limited number of threads available. This setting
79+
/// restricts how many tasks are spawned concurrently.
80+
pub max_blocking_io_requests: usize,
7181
/// Maximum number of blocks for `trace_filter` requests.
7282
pub max_trace_filter_blocks: u64,
7383
/// Maximum number of blocks that could be scanned per filter request in `eth_getLogs` calls.
@@ -116,6 +126,7 @@ impl Default for EthConfig {
116126
gas_oracle: GasPriceOracleConfig::default(),
117127
eth_proof_window: DEFAULT_ETH_PROOF_WINDOW,
118128
max_tracing_requests: default_max_tracing_requests(),
129+
max_blocking_io_requests: DEFAULT_MAX_BLOCKING_IO_REQUEST,
119130
max_trace_filter_blocks: DEFAULT_MAX_TRACE_FILTER_BLOCKS,
120131
max_blocks_per_filter: DEFAULT_MAX_BLOCKS_PER_FILTER,
121132
max_logs_per_response: DEFAULT_MAX_LOGS_PER_RESPONSE,
@@ -152,6 +163,12 @@ impl EthConfig {
152163
self
153164
}
154165

166+
/// Configures the maximum number of blocking IO requests
167+
pub const fn max_blocking_io_requests(mut self, max_requests: usize) -> Self {
168+
self.max_blocking_io_requests = max_requests;
169+
self
170+
}
171+
155172
/// Configures the maximum block length to scan per `eth_getLogs` request
156173
pub const fn max_blocks_per_filter(mut self, max_blocks: u64) -> Self {
157174
self.max_blocks_per_filter = max_blocks;

crates/rpc/rpc-server-types/src/constants.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,20 @@ pub const DEFAULT_MAX_LOGS_PER_RESPONSE: usize = 20_000;
1818
/// The default maximum number of blocks for `trace_filter` requests.
1919
pub const DEFAULT_MAX_TRACE_FILTER_BLOCKS: u64 = 100;
2020

21+
/// Setting for how many concurrent (heavier) _blocking_ IO requests are allowed.
22+
///
23+
/// What is considered a blocking IO request can depend on the RPC method. In general anything that
24+
/// requires IO is considered blocking and should be spawned as blocking. This setting is however,
25+
/// primarily intended for heavier blocking requests that require evm execution for example,
26+
/// `eth_call` and alike. This is intended to be used with a semaphore that must be acquired before
27+
/// a new task is spawned to avoid unnecessary pooling if the number of inflight requests exceeds
28+
/// the available threads in the pool.
29+
///
30+
/// tokio's blocking pool, has a default of 512 and could grow unbounded, since requests like
31+
/// `eth_call` also require a lot of cpu which will occupy the thread, we can set this to a lower
32+
/// value.
33+
pub const DEFAULT_MAX_BLOCKING_IO_REQUEST: usize = 256;
34+
2135
/// The default maximum number tracing requests we're allowing concurrently.
2236
/// Tracing is mostly CPU bound so we're limiting the number of concurrent requests to something
2337
/// lower that the number of cores, in order to minimize the impact on the rest of the system.

0 commit comments

Comments
 (0)