Skip to content

Commit 75ff4d6

Browse files
committed
passthrough: serve blocking READ and WRITE on the thread pool
On the tokio runtime, PassthroughFs serves READ cache misses and every WRITE with a blocking preadv() or pwritev() on the runtime thread, even with enable_async_thread_pool(). A slow backing file then stalls every other request of the worker. The O_DIRECT and WRITE_KILL_PRIV requests that it relays to the synchronous handlers block the runtime thread on any runtime. With the pool enabled, serve these requests with the synchronous handlers on a pool thread. The pool thread never touches the transport buffer, because the request frees it when its future is dropped, for example for a takeover. A READ reads into a buffer that the pool task owns, and the runtime thread copies it into the reply. A READ larger than the reply buffer fails before the task allocates. A WRITE copies its payload out of the request before the task starts. A task that no pool thread has started when its request is dropped doesn't run, so the old copy of a WRITE can't overwrite newer data after a takeover. Measured with one worker on the tokio runtime and the backing files on ext4: - With a READ waiting in a FUSE file system below, reading another file through the mount takes 1.6 ms instead of 2 s. - fio random 4 KiB reads with 4 jobs and a cold page cache reach 34.5K IOPS instead of 12.1K. - Cached fio random 4 KiB writes reach 424K IOPS instead of 433K. The copy and the thread handoff add about 3.5 µs to a 4 KiB READ miss or WRITE. READ cache hits stay inline on file systems with RWF_NOWAIT. On others, such as tmpfs, overlayfs and FUSE, every READ counts as a miss. io_uring keeps its native path for buffered READ and WRITE. Signed-off-by: Ilan Joselevich <personal@ilanjoselevich.com>
1 parent 5fec9bc commit 75ff4d6

4 files changed

Lines changed: 1165 additions & 61 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@
5454
`preadv()`/`pwritev()` call for all buffers instead of one call per buffer.
5555
`async_file::preadv()` and `pwritev()` accept more than 1024 buffers and make one call per
5656
1024.
57+
- `PassthroughFs::enable_async_thread_pool(true)` also moves READ and WRITE
58+
requests that would block the runtime thread to the blocking thread pool.
59+
These are the O_DIRECT and WRITE_KILL_PRIV requests, and on the tokio
60+
runtime also READ cache misses and every WRITE. On a file system without
61+
`RWF_NOWAIT`, such as tmpfs, overlayfs or FUSE, every READ counts as a cache
62+
miss. Each of these requests costs one more copy and a thread handoff.
5763
- The umbrella crate's `async-io` no longer turns on the `fusedev` and `virtiofs`
5864
transports. Code that uses the items they gate, such as `abi::virtio_fs`, has
5965
to enable them. A crate that depends on a transport crate directly must enable

‎crates/fuse-backend-fusedev/tests/tokio_runtime.rs‎

Lines changed: 293 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,26 @@ mod tokio_runtime {
1010
use std::ffi::CStr;
1111
use std::fs::File;
1212
use std::io;
13+
use std::os::unix::fs::MetadataExt;
1314
use std::os::unix::io::AsRawFd;
1415
use std::path::Path;
1516
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
16-
use std::sync::mpsc::channel;
17-
use std::sync::Arc;
17+
use std::sync::mpsc::{channel, Receiver};
18+
use std::sync::{Arc, Condvar, Mutex};
1819
use std::thread;
1920
use std::time::Duration;
2021

2122
use async_trait::async_trait;
2223
use fuse_backend_core::abi::fuse_abi::{stat64, CreateIn, FsOptions, OpenOptions, ROOT_ID};
2324
use fuse_backend_core::api::filesystem::{
2425
AsyncFileSystem, AsyncZeroCopyReader, AsyncZeroCopyWriter, Context, Entry, FileSystem,
25-
SetattrValid,
26+
SetattrValid, ZeroCopyWriter,
2627
};
2728
use fuse_backend_core::api::server::Server;
2829
use fuse_backend_core::async_runtime::{self, RuntimeType};
29-
use fuse_backend_fusedev::{FuseDevTask, FuseDevWriter, FuseSession};
30+
use fuse_backend_fusedev::{
31+
FuseDevTask, FuseDevWriter, FuseSession, SyncFuseServing, SyncServingConfig,
32+
};
3033
use fuse_backend_passthrough::{Config, PassthroughFs};
3134
use tokio::sync::{oneshot, Semaphore};
3235
use vmm_sys_util::tempdir::TempDir;
@@ -95,15 +98,10 @@ mod tokio_runtime {
9598
let mut task = FuseDevTask::new(buf_size, file, server, state);
9699
block_on_without_tokio(task.poll_handler());
97100
});
98-
let (tx, rx) = channel();
99101
let path = mnt.as_path().join("hello");
100-
let reader = thread::spawn(move || {
101-
let _ = tx.send(std::fs::read(path));
102-
});
103-
let read = rx.recv_timeout(Duration::from_secs(5));
102+
let read = in_thread(move || std::fs::read(path)).recv_timeout(Duration::from_secs(5));
104103
se.umount().unwrap();
105104
let served = handle.join();
106-
reader.join().unwrap();
107105
assert!(
108106
served.is_ok(),
109107
"poll_handler() panicked without a tokio runtime"
@@ -218,12 +216,11 @@ mod tokio_runtime {
218216
}
219217
}
220218

221-
/// Stat `path` in a thread of its own and send the inode number.
222-
fn stat_in_thread(path: std::path::PathBuf) -> std::sync::mpsc::Receiver<u64> {
219+
/// Run `f` in a thread of its own and send its result.
220+
fn in_thread<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Receiver<T> {
223221
let (tx, rx) = channel();
224222
thread::spawn(move || {
225-
use std::os::unix::fs::MetadataExt;
226-
let _ = tx.send(std::fs::metadata(path).unwrap().ino());
223+
let _ = tx.send(f());
227224
});
228225
rx
229226
}
@@ -407,10 +404,10 @@ mod tokio_runtime {
407404

408405
/// Serve `file` with `server` on a tokio runtime of its own until the task returns or `stop`
409406
/// completes. Stopping drops the requests in flight.
410-
fn serve(
407+
fn serve<F: AsyncFileSystem + Send + Sync + 'static>(
411408
file: File,
412409
buf_size: usize,
413-
server: Arc<Server<Arc<TestFs>>>,
410+
server: Arc<Server<F>>,
414411
stop: oneshot::Receiver<()>,
415412
) -> thread::JoinHandle<()> {
416413
thread::spawn(move || {
@@ -451,11 +448,11 @@ mod tokio_runtime {
451448
stop,
452449
);
453450

454-
let slow = stat_in_thread(mnt.as_path().join("slow"));
451+
let path = mnt.as_path().join("slow");
452+
let slow = in_thread(move || std::fs::metadata(path).unwrap().ino());
455453
thread::sleep(Duration::from_millis(100));
456454

457455
// A lookup waiting in the file system doesn't hold up the others.
458-
use std::os::unix::fs::MetadataExt;
459456
let fast = std::fs::metadata(mnt.as_path().join("fast")).unwrap();
460457
assert_eq!(fast.ino(), FAST);
461458
assert!(slow.try_recv().is_err());
@@ -511,7 +508,8 @@ mod tokio_runtime {
511508
old_handle.join().unwrap();
512509
return;
513510
}
514-
let slow = stat_in_thread(mnt.as_path().join("slow"));
511+
let path = mnt.as_path().join("slow");
512+
let slow = in_thread(move || std::fs::metadata(path).unwrap().ino());
515513
thread::sleep(Duration::from_millis(100));
516514
stop_tx.send(()).unwrap();
517515
old_handle.join().unwrap();
@@ -533,4 +531,280 @@ mod tokio_runtime {
533531
se.umount().unwrap();
534532
new_handle.join().unwrap();
535533
}
534+
535+
/// Size of the files of `SlowFs`.
536+
const SLOW_FS_FILE_SIZE: usize = 64 << 10;
537+
538+
/// The content of the files of `SlowFs`.
539+
fn slow_fs_data() -> Vec<u8> {
540+
(0..SLOW_FS_FILE_SIZE).map(|i| (i % 251) as u8).collect()
541+
}
542+
543+
/// A root directory with the files `fast` and `slow`, which `SyncFuseServing` serves. Reading
544+
/// `slow` waits until the gate opens. The files are direct IO, so every read reaches the file
545+
/// system.
546+
#[derive(Default)]
547+
struct SlowFs {
548+
gate: Mutex<bool>,
549+
opened: Condvar,
550+
slow_reads: AtomicUsize,
551+
has_resend: AtomicBool,
552+
}
553+
554+
impl SlowFs {
555+
fn open_gate(&self) {
556+
*self.gate.lock().unwrap() = true;
557+
self.opened.notify_all();
558+
}
559+
560+
/// Wait until `n` reads of `slow` have started.
561+
fn wait_for_slow_reads(&self, n: usize) {
562+
for _ in 0..500 {
563+
if self.slow_reads.load(Ordering::Acquire) >= n {
564+
return;
565+
}
566+
thread::sleep(Duration::from_millis(10));
567+
}
568+
panic!("the file system got no read of slow");
569+
}
570+
571+
fn stat(inode: u64) -> stat64 {
572+
let mut st = stat(inode);
573+
if inode != ROOT_ID {
574+
st.st_size = SLOW_FS_FILE_SIZE as i64;
575+
}
576+
st
577+
}
578+
}
579+
580+
#[allow(unused_variables)]
581+
impl FileSystem for SlowFs {
582+
type Inode = u64;
583+
type Handle = u64;
584+
585+
fn init(&self, capable: FsOptions) -> io::Result<FsOptions> {
586+
self.has_resend
587+
.store(capable.contains(FsOptions::HAS_RESEND), Ordering::Relaxed);
588+
Ok(FsOptions::empty())
589+
}
590+
591+
fn lookup(&self, ctx: &Context, parent: u64, name: &CStr) -> io::Result<Entry> {
592+
let inode = match name.to_bytes() {
593+
b"fast" => FAST,
594+
b"slow" => SLOW,
595+
_ => return Err(io::Error::from_raw_os_error(libc::ENOENT)),
596+
};
597+
Ok(Entry {
598+
attr: Self::stat(inode),
599+
..entry(inode)
600+
})
601+
}
602+
603+
fn forget(&self, ctx: &Context, inode: u64, count: u64) {}
604+
605+
fn getattr(
606+
&self,
607+
ctx: &Context,
608+
inode: u64,
609+
handle: Option<u64>,
610+
) -> io::Result<(stat64, Duration)> {
611+
Ok((Self::stat(inode), Duration::from_secs(60)))
612+
}
613+
614+
fn open(
615+
&self,
616+
ctx: &Context,
617+
inode: u64,
618+
flags: u32,
619+
fuse_flags: u32,
620+
) -> io::Result<(Option<u64>, OpenOptions, Option<u32>)> {
621+
Ok((Some(inode), OpenOptions::DIRECT_IO, None))
622+
}
623+
624+
fn read(
625+
&self,
626+
ctx: &Context,
627+
inode: u64,
628+
handle: u64,
629+
w: &mut dyn ZeroCopyWriter,
630+
size: u32,
631+
offset: u64,
632+
lock_owner: Option<u64>,
633+
flags: u32,
634+
) -> io::Result<usize> {
635+
if inode == SLOW {
636+
self.slow_reads.fetch_add(1, Ordering::AcqRel);
637+
let mut open = self.gate.lock().unwrap();
638+
while !*open {
639+
open = self.opened.wait(open).unwrap();
640+
}
641+
}
642+
let data = slow_fs_data();
643+
let start = std::cmp::min(offset as usize, data.len());
644+
let end = std::cmp::min(start + size as usize, data.len());
645+
w.write_all(&data[start..end])?;
646+
Ok(end - start)
647+
}
648+
649+
fn release(
650+
&self,
651+
ctx: &Context,
652+
inode: u64,
653+
flags: u32,
654+
handle: u64,
655+
flush: bool,
656+
flock_release: bool,
657+
lock_owner: Option<u64>,
658+
) -> io::Result<()> {
659+
Ok(())
660+
}
661+
}
662+
663+
/// A mounted `SlowFs`, and an unserved mount for a `PassthroughFs` of it. Dropping it opens
664+
/// the gate first, so a failing test doesn't leave reads waiting. Then the fields drop in
665+
/// order, so the upper mount, and the files that `fs` holds open in the lower mount, go away
666+
/// before the lower mount.
667+
struct SlowFsStack {
668+
upper_se: FuseSession,
669+
fs: Arc<PassthroughFs<()>>,
670+
_lower: SyncFuseServing<Arc<SlowFs>>,
671+
slow_fs: Arc<SlowFs>,
672+
}
673+
674+
impl Drop for SlowFsStack {
675+
fn drop(&mut self) {
676+
self.slow_fs.open_gate();
677+
}
678+
}
679+
680+
/// Mount a `SlowFs` at `lower`, served by threads of its own, and create a `PassthroughFs`
681+
/// of it with the blocking thread pool enabled, for a mount at `upper`.
682+
fn mount_passthrough_of_slow_fs(lower: &Path, upper: &Path) -> Option<SlowFsStack> {
683+
let slow_fs = Arc::new(SlowFs::default());
684+
let lower_se = mount(lower)?;
685+
let serving = SyncFuseServing::new(
686+
lower_se,
687+
Arc::new(Server::new(slow_fs.clone())),
688+
SyncServingConfig {
689+
workers: 4,
690+
blocking: false,
691+
},
692+
)
693+
.unwrap();
694+
let fs = PassthroughFs::<()>::new_shared(Config {
695+
root_dir: lower.to_string_lossy().to_string(),
696+
do_import: false,
697+
..Default::default()
698+
})
699+
.unwrap();
700+
fs.import().unwrap();
701+
fs.enable_async_thread_pool(true);
702+
let upper_se = mount(upper)?;
703+
Some(SlowFsStack {
704+
upper_se,
705+
fs,
706+
_lower: serving,
707+
slow_fs,
708+
})
709+
}
710+
711+
/// With the thread pool, a READ that waits for the backing file doesn't hold up the other
712+
/// requests on the current-thread runtime.
713+
#[test]
714+
fn test_passthrough_pool_serves_while_read_waits() {
715+
async_runtime::set_runtime_type(RuntimeType::Tokio).unwrap();
716+
717+
let lower = TempDir::new().unwrap();
718+
let upper = TempDir::new().unwrap();
719+
let Some(mut stack) = mount_passthrough_of_slow_fs(lower.as_path(), upper.as_path()) else {
720+
return;
721+
};
722+
let (_stop_tx, stop) = oneshot::channel();
723+
let handle = serve(
724+
stack.upper_se.clone_fuse_file().unwrap(),
725+
stack.upper_se.bufsize(),
726+
Arc::new(Server::new(stack.fs.clone())),
727+
stop,
728+
);
729+
730+
let path = upper.as_path().join("slow");
731+
let slow = in_thread(move || std::fs::read(path));
732+
stack.slow_fs.wait_for_slow_reads(1);
733+
// Without the pool, the runtime thread waits for the gate, which opens 2s from now.
734+
let backstop = stack.slow_fs.clone();
735+
thread::spawn(move || {
736+
thread::sleep(Duration::from_secs(2));
737+
backstop.open_gate();
738+
});
739+
let start = std::time::Instant::now();
740+
let fast = std::fs::read(upper.as_path().join("fast")).unwrap();
741+
let elapsed = start.elapsed();
742+
eprintln!("reading fast while slow waits took {:?}", elapsed);
743+
assert_eq!(fast, slow_fs_data());
744+
assert!(
745+
elapsed < Duration::from_secs(1),
746+
"reading fast took {:?}",
747+
elapsed
748+
);
749+
assert!(slow.try_recv().is_err());
750+
751+
stack.slow_fs.open_gate();
752+
let slow = slow.recv_timeout(Duration::from_secs(5)).unwrap().unwrap();
753+
assert_eq!(slow, slow_fs_data());
754+
755+
stack.upper_se.umount().unwrap();
756+
handle.join().unwrap();
757+
}
758+
759+
/// A server that stops with a READ waiting on a pool thread frees the request's buffer while
760+
/// the pool thread still reads. The pool thread reads into a buffer of its own, and a new
761+
/// server answers the READ that the kernel sends again.
762+
#[test]
763+
fn test_passthrough_pool_read_survives_takeover() {
764+
async_runtime::set_runtime_type(RuntimeType::Tokio).unwrap();
765+
766+
let lower = TempDir::new().unwrap();
767+
let upper = TempDir::new().unwrap();
768+
let Some(mut stack) = mount_passthrough_of_slow_fs(lower.as_path(), upper.as_path()) else {
769+
return;
770+
};
771+
// `import()` stat'ed the root of the lower mount, which waited for its INIT.
772+
if !stack.slow_fs.has_resend.load(Ordering::Relaxed) {
773+
eprintln!("kernel without FUSE_NOTIFY_RESEND, skipping");
774+
return;
775+
}
776+
let buf_size = stack.upper_se.bufsize();
777+
// The kernel ends the requests read from a file description when it is closed, so the
778+
// test keeps the one the old server reads from, as a takeover passes it to the new one.
779+
let file = stack.upper_se.clone_fuse_file().unwrap();
780+
let old = Arc::new(Server::new(stack.fs.clone()));
781+
let (stop_tx, stop) = oneshot::channel();
782+
let old_handle = serve(file.try_clone().unwrap(), buf_size, old.clone(), stop);
783+
784+
let path = upper.as_path().join("slow");
785+
let slow = in_thread(move || std::fs::read(path));
786+
stack.slow_fs.wait_for_slow_reads(1);
787+
// Dropping poll_handler() frees the request buffers. The runtime then waits for its pool
788+
// threads when it shuts down, so the old serving thread ends only after the gate opens.
789+
stop_tx.send(()).unwrap();
790+
thread::sleep(Duration::from_millis(100));
791+
792+
let new = Arc::new(Server::new(stack.fs.clone()));
793+
let (version, options) = old.init_state();
794+
new.restore_init(version, options);
795+
let mut buf = vec![0u8; 0x1000];
796+
let writer = FuseDevWriter::<()>::new(file.as_raw_fd(), &mut buf).unwrap();
797+
new.notify_resend(writer).unwrap();
798+
let (_stop_tx, stop) = oneshot::channel();
799+
let new_handle = serve(file.try_clone().unwrap(), buf_size, new, stop);
800+
stack.slow_fs.wait_for_slow_reads(2);
801+
802+
stack.slow_fs.open_gate();
803+
let slow = slow.recv_timeout(Duration::from_secs(5)).unwrap().unwrap();
804+
assert_eq!(slow, slow_fs_data());
805+
old_handle.join().unwrap();
806+
807+
stack.upper_se.umount().unwrap();
808+
new_handle.join().unwrap();
809+
}
536810
}

0 commit comments

Comments
 (0)