fsync와 fdatasync는 무엇을 기다리며 어디까지 저장할까요?

파일을 쓴 뒤 fsync()를 호출하는 이유는 dirty data를 메모리에 남긴 채 성공했다고 판단하지 않기 위해서입니다. 하지만 sync(), syncfs(), fsync(), fdatasync()가 모두 같은 범위를 같은 방식으로 처리하는 것은 아닙니다. 동기화 대상과 error를 받는 단위부터 다릅니다.

더구나 data I/O를 제출하는 것, 그 완료를 기다리는 것, filesystem metadata와 device cache를 필요한 수준으로 동기화하는 것은 별도의 책임입니다. API 이름을 외우기보다 이 책임이 어디에 있는지 살펴보겠습니다.

동기화 범위를 먼저 정합니다

sync()는 시스템의 filesystem들을 대상으로 합니다. syncfs(fd)는 fd가 속한 filesystem을 대상으로 하며 그 파일 하나에만 제한되지 않습니다. fsync(fd)와 fdatasync(fd)는 file 단위 요청입니다. fd가 가리키는 열린 file을 안정적으로 잡은 상태에서 VFS가 filesystem callback에 작업을 넘깁니다.

Linux의 sync()는 I/O 완료를 기다리는 동작을 하지만 반환값으로 개별 writeback error를 전달하는 API는 아닙니다. syncfs()와 file sync의 error 처리까지 같은 것으로 생각하면 저장 실패를 놓칠 수 있습니다. sync와 syncfs manual

아래는 v6.18의 sync syscall 전체입니다. kernel wrapper는 0을 반환하지만 사용자 공간 sync()의 prototype은 void입니다. 개별 error를 errno로 받아 확인하는 file sync와 구별해야 합니다.

syscall_sync_v6_18.c

// SPDX-License-Identifier: GPL-2.0
/*
 * Complete scope from Linux v6.18 fs/sync.c.
 * Source: https://github.com/torvalds/linux/blob/v6.18/fs/sync.c
 * Kernel excerpt; not a standalone program.
 * The book's exact source tag is not asserted.
 */

SYSCALL_DEFINE0(sync)
{
	ksys_sync();
	return 0;
}

msync(MS_SYNC)는 shared file mapping의 범위 동기화에 사용합니다. munmap()은 이를 대체하는 durability 요청이 아닙니다. sync_file_range()도 이름만 보고 fsync 대용으로 사용해서는 안 됩니다. 이 API의 범위 I/O 제어는 필요한 metadata 동기화나 device cache flush를 모두 제공하는 계약이 아닙니다. sync_file_range manual

fdatasync도 필요한 metadata는 저장합니다

fdatasync()를 metadata를 전혀 쓰지 않는 fsync라고 설명하면 틀립니다. 나중에 data를 올바르게 찾아 읽는 데 필요한 metadata는 포함됩니다. 파일 크기를 늘렸다면 그 size 정보 없이 새 data만 저장하는 것으로는 충분하지 않습니다. 반면 단순한 timestamp 변경은 같은 수준으로 요구되지 않을 수 있습니다.

fsync()는 file data와 관련 metadata를 대상으로 하지만, 새 이름을 만든 directory entry까지 반드시 저장했다는 뜻은 아닙니다. 이름의 지속성을 요구하는 작업에서는 directory 자체의 동기화 문제를 별도로 다루어야 합니다. 또한 이 API가 여러 application write를 하나의 원자적인 transaction으로 바꾸지는 않습니다. fsync와 fdatasync manual

upstream v6.18의 vfs_fsync_range()는 file->f_op->fsync를 확인하고 callback을 호출합니다. callback이 없으면 -EINVAL입니다. 일반 fsync에서는 지연된 timestamp state도 적절히 승격할 수 있습니다. 따라서 모든 filesystem이 같은 data 구조를 같은 순서로 저장한다고 단정할 수 없습니다. v6.18 fs/sync.c

현재 do_fsync()는 CLASS(fd, f)의 수명 정리를 사용합니다. fd가 유효하지 않으면 -EBADF이고, 유효하면 file 객체를 안정적으로 사용한 채 vfs_fsync()에 맡깁니다. 예전 fdget()이 언제나 실제 refcount increment라고 일반화해서도 안 됩니다. fd helper는 필요에 따라 참조를 유지하는 계약이며, caller가 임의로 추가 put을 하면 안 됩니다. 다음 세 함수는 v6.18 전체 정의입니다.

do_fsync_v6_18.c

// SPDX-License-Identifier: GPL-2.0
/*
 * Complete scope from Linux v6.18 fs/sync.c.
 * Source: https://github.com/torvalds/linux/blob/v6.18/fs/sync.c
 * Kernel excerpt; not a standalone program.
 * The book's exact source tag is not asserted.
 */

static int do_fsync(unsigned int fd, int datasync)
{
	CLASS(fd, f)(fd);

	if (fd_empty(f))
		return -EBADF;

	return vfs_fsync(fd_file(f), datasync);
}

vfs_fsync_v6_18.c

// SPDX-License-Identifier: GPL-2.0
/*
 * Complete scope from Linux v6.18 fs/sync.c.
 * Source: https://github.com/torvalds/linux/blob/v6.18/fs/sync.c
 * Kernel excerpt; not a standalone program.
 * The book's exact source tag is not asserted.
 */

int vfs_fsync(struct file *file, int datasync)
{
	return vfs_fsync_range(file, 0, LLONG_MAX, datasync);
}

vfs_fsync_range_v6_18.c

// SPDX-License-Identifier: GPL-2.0
/*
 * Complete scope from Linux v6.18 fs/sync.c.
 * Source: https://github.com/torvalds/linux/blob/v6.18/fs/sync.c
 * Kernel excerpt; not a standalone program.
 * The book's exact source tag is not asserted.
 */

int vfs_fsync_range(struct file *file, loff_t start, loff_t end, int datasync)
{
	struct inode *inode = file->f_mapping->host;

	if (!file->f_op->fsync)
		return -EINVAL;
	if (!datasync && (inode->i_state & I_DIRTY_TIME))
		mark_inode_dirty_sync(inode);
	return file->f_op->fsync(file, start, end, datasync);
}

범위 끝은 inclusive이며 file 전체 wrapper는 0부터 LLONG_MAX까지를 전달합니다. datasync가 아닌 요청에서 I_DIRTY_TIME이 보이면 metadata dirty로 승격한 뒤 filesystem callback을 호출합니다. missing callback 오류를 성공적인 no-op으로 처리하지 않습니다.

전체 sync는 먼저 병렬 진행을 시작합니다

ksys_sync()는 flusher들을 깨워 여러 device에서 writeback을 시작하게 한 뒤 superblock들을 순회하며 inode writeback을 동기화합니다. 이후 filesystem의 sync_fs callback을 non-wait와 wait 단계로 호출하고 block device 쪽 동기화도 두 단계로 수행합니다.

처음부터 device 하나의 모든 작업이 끝날 때까지 기다린 다음 다음 device를 시작하는 방식이 아닙니다. 제출을 먼저 넓게 진행하면 대기할 때 이미 I/O가 상당 부분 끝나 있을 수 있습니다. 다만 callback과 block device 처리가 겹치는 이유는 filesystem metadata가 block-device cache에도 남을 수 있기 때문입니다.

다음은 이 순서를 실제로 구현하는 v6.18 전체 함수와 callback 둘입니다. sync_inodes_one_sb()는 read-only superblock을 건너뜁니다. metadata callback에는 SB_I_SKIP_SYNC와 callback 존재 조건도 있습니다. ksys_sync()가 각 callback의 error를 모두 모아 syscall error로 돌려주는 함수는 아니라는 점도 실제 반환형과 함께 확인할 수 있습니다.

ksys_sync_v6_18.c

// SPDX-License-Identifier: GPL-2.0
/*
 * Complete scope from Linux v6.18 fs/sync.c.
 * Source: https://github.com/torvalds/linux/blob/v6.18/fs/sync.c
 * Kernel excerpt; not a standalone program.
 * The book's exact source tag is not asserted.
 */

void ksys_sync(void)
{
	int nowait = 0, wait = 1;

	wakeup_flusher_threads(WB_REASON_SYNC);
	iterate_supers(sync_inodes_one_sb, NULL);
	iterate_supers(sync_fs_one_sb, &nowait);
	iterate_supers(sync_fs_one_sb, &wait);
	sync_bdevs(false);
	sync_bdevs(true);
	if (unlikely(laptop_mode))
		laptop_sync_completion();
}

sync_inodes_one_sb_v6_18.c

// SPDX-License-Identifier: GPL-2.0
/*
 * Complete scope from Linux v6.18 fs/sync.c.
 * Source: https://github.com/torvalds/linux/blob/v6.18/fs/sync.c
 * Kernel excerpt; not a standalone program.
 * The book's exact source tag is not asserted.
 */

static void sync_inodes_one_sb(struct super_block *sb, void *arg)
{
	if (!sb_rdonly(sb))
		sync_inodes_sb(sb);
}

sync_fs_one_sb_v6_18.c

// SPDX-License-Identifier: GPL-2.0
/*
 * Complete scope from Linux v6.18 fs/sync.c.
 * Source: https://github.com/torvalds/linux/blob/v6.18/fs/sync.c
 * Kernel excerpt; not a standalone program.
 * The book's exact source tag is not asserted.
 */

static void sync_fs_one_sb(struct super_block *sb, void *arg)
{
	if (!sb_rdonly(sb) && !(sb->s_iflags & SB_I_SKIP_SYNC) &&
	    sb->s_op->sync_fs)
		sb->s_op->sync_fs(sb, *(int *)arg);
}

sync_filesystem()도 비슷하게 제출과 대기를 나눕니다. read-only superblock이면 작업하지 않습니다. writeback inode 제출, sync_fs(..., 0), block-device nowait 단계를 거친 다음 sync_inodes_sb(), sync_fs(..., 1), block-device wait로 이어집니다. 각 단계에서 발생한 error에 따라 뒤 작업으로 진행하지 않고 반환할 수 있습니다. 이 함수는 호출자가 s_umount로 mount 상태 변화를 막는 조건을 요구합니다.

아래 전체 정의는 이 lock을 내부에서 새로 얻는 함수가 아닙니다. caller의 s_umount 보호를 검사한 뒤 각 단계의 반환값을 처리합니다. 이어 보이는 syncfs syscall 쪽이 read lock 획득·해제와 filesystem error cursor 갱신을 수행합니다.

sync_filesystem_v6_18.c

// SPDX-License-Identifier: GPL-2.0
/*
 * Complete scope from Linux v6.18 fs/sync.c.
 * Source: https://github.com/torvalds/linux/blob/v6.18/fs/sync.c
 * Kernel excerpt; not a standalone program.
 * The book's exact source tag is not asserted.
 */

int sync_filesystem(struct super_block *sb)
{
	int ret = 0;

	/*
	 * We need to be protected against the filesystem going from
	 * r/o to r/w or vice versa.
	 */
	WARN_ON(!rwsem_is_locked(&sb->s_umount));

	/*
	 * No point in syncing out anything if the filesystem is read-only.
	 */
	if (sb_rdonly(sb))
		return 0;

	/*
	 * Do the filesystem syncing work.  For simple filesystems
	 * writeback_inodes_sb(sb) just dirties buffers with inodes so we have
	 * to submit I/O for these buffers via sync_blockdev().  This also
	 * speeds up the wait == 1 case since in that case write_inode()
	 * methods call sync_dirty_buffer() and thus effectively write one block
	 * at a time.
	 */
	writeback_inodes_sb(sb, WB_REASON_SYNC);
	if (sb->s_op->sync_fs) {
		ret = sb->s_op->sync_fs(sb, 0);
		if (ret)
			return ret;
	}
	ret = sync_blockdev_nowait(sb->s_bdev);
	if (ret)
		return ret;

	sync_inodes_sb(sb);
	if (sb->s_op->sync_fs) {
		ret = sb->s_op->sync_fs(sb, 1);
		if (ret)
			return ret;
	}
	return sync_blockdev(sb->s_bdev);
}

syscall_syncfs_v6_18.c

// SPDX-License-Identifier: GPL-2.0
/*
 * Complete scope from Linux v6.18 fs/sync.c.
 * Source: https://github.com/torvalds/linux/blob/v6.18/fs/sync.c
 * Kernel excerpt; not a standalone program.
 * The book's exact source tag is not asserted.
 */

SYSCALL_DEFINE1(syncfs, int, fd)
{
	CLASS(fd, f)(fd);
	struct super_block *sb;
	int ret, ret2;

	if (fd_empty(f))
		return -EBADF;
	sb = fd_file(f)->f_path.dentry->d_sb;

	down_read(&sb->s_umount);
	ret = sync_filesystem(sb);
	up_read(&sb->s_umount);

	ret2 = errseq_check_and_advance(&sb->s_wb_err, &fd_file(f)->f_sb_err);

	return ret ? ret : ret2;
}

syncfs()는 invalid fd를 거절하고, sync_filesystem()의 error와 superblock의 s_wb_err를 확인합니다. 동기화 자체가 error이면 그 결과를 우선하고, 아니면 error sequence에서 새로 관찰한 결과를 돌려줍니다. 대상은 fd의 파일 하나가 아니라 그 파일이 속한 filesystem입니다.

file callback은 필요한 저장 규칙을 조합합니다

단순한 buffer 기반 filesystem을 위한 __generic_file_fsync()는 먼저 file_write_and_wait_range()로 data를 처리합니다. 성공하면 inode lock을 얻고 관련 mapping buffer들을 동기화합니다. 이어 inode dirty state와 datasync 조건에 맞춰 metadata를 씁니다.

여기서 datasync의 판단에 쓰는 것은 I_DIRTY_DATASYNC입니다. I_DIRTY_PAGES 하나만 확인해서 metadata 필요성을 결정하는 것이 아닙니다. 이미 data dirty bit가 정리되었어도 접근에 필요한 metadata가 남을 수 있습니다. 마지막에는 file별 writeback error cursor를 다시 확인하여 buffer 동기화 중 발생한 error도 잡습니다. v6.18 fs/libfs.c

generic_file_fsync()는 이 처리가 성공한 뒤 blkdev_issue_flush()까지 수행하는 wrapper입니다. filesystem의 자체 journal이나 ordering이 있다면 callback 구현은 달라집니다. generic helper의 순서를 모든 filesystem의 crash-consistency 설명으로 확장하지 않는 것이 중요합니다.

다음은 v6.18 fs/libfs.c의 두 전체 정의입니다. mapping buffer 처리에서 이미 error가 있으면 뒤 metadata·file error 결과로 그 첫 error를 덮지 않습니다. inode lock 밖에서 error cursor를 한 번 더 확인하여 buffer 동기화 중 발생한 오류도 놓치지 않습니다.

__generic_file_fsync_v6_18.c

// SPDX-License-Identifier: GPL-2.0-only
/*
 * Complete scope from Linux v6.18 fs/libfs.c.
 * Source: https://github.com/torvalds/linux/blob/v6.18/fs/libfs.c
 * Kernel excerpt; not a standalone program.
 * The book's exact source tag is not asserted.
 */

int __generic_file_fsync(struct file *file, loff_t start, loff_t end,
				 int datasync)
{
	struct inode *inode = file->f_mapping->host;
	int err;
	int ret;

	err = file_write_and_wait_range(file, start, end);
	if (err)
		return err;

	inode_lock(inode);
	ret = sync_mapping_buffers(inode->i_mapping);
	if (!(inode->i_state & I_DIRTY_ALL))
		goto out;
	if (datasync && !(inode->i_state & I_DIRTY_DATASYNC))
		goto out;

	err = sync_inode_metadata(inode, 1);
	if (ret == 0)
		ret = err;

out:
	inode_unlock(inode);
	/* check and advance again to catch errors after syncing out buffers */
	err = file_check_and_advance_wb_err(file);
	if (ret == 0)
		ret = err;
	return ret;
}

generic_file_fsync_v6_18.c

// SPDX-License-Identifier: GPL-2.0-only
/*
 * Complete scope from Linux v6.18 fs/libfs.c.
 * Source: https://github.com/torvalds/linux/blob/v6.18/fs/libfs.c
 * Kernel excerpt; not a standalone program.
 * The book's exact source tag is not asserted.
 */

int generic_file_fsync(struct file *file, loff_t start, loff_t end,
		       int datasync)
{
	struct inode *inode = file->f_mapping->host;
	int err;

	err = __generic_file_fsync(file, start, end, datasync);
	if (err)
		return err;
	return blkdev_issue_flush(inode->i_sb->s_bdev);
}

동기 write의 성공값도 끝까지 확인합니다

O_SYNC, O_DSYNC 또는 대응하는 I/O flag가 설정된 generic write 경로는 복사 이후 generic_write_sync()를 통해 vfs_fsync_range()로 이어질 수 있습니다. 범위는 실제로 쓴 byte를 기준으로 하며, sync가 실패하면 복사한 양만 보고 성공으로 끝내지 않습니다.

이는 보통 write가 반환된 뒤 background error가 나중의 fsync에서 발견되는 상황과도 이어집니다. 저장을 요구하는 호출은 data가 cache에 들어갔다는 사실뿐 아니라 완료와 error 보고까지 확인해야 합니다. device와 filesystem이 제공하는 계약 안에서 성공을 판단하는 것이지, dirty counter가 0으로 보였다는 관찰로 대신하는 것이 아닙니다.

아래는 v6.18 generic_write_sync() 전체입니다. ki_pos가 이미 전진한 상태에서 실제 쓴 범위를 계산하고 IOCB_SYNC 여부로 datasync를 선택합니다. 현재의 IOCB_DONTCACHE else 분기는 filemap_fdatawrite_range_kick()으로 해당 범위 writeback을 진행시키는 별도의 요청입니다. 이 분기를 fsync와 같은 완료 대기나 durability 보장으로 설명하지 않습니다.

generic_write_sync_v6_18.c

// SPDX-License-Identifier: GPL-2.0
/*
 * Complete scope from Linux v6.18 include/linux/fs.h.
 * Source: https://github.com/torvalds/linux/blob/v6.18/include/linux/fs.h
 * Kernel excerpt; not a standalone program.
 * The book's exact source tag is not asserted.
 */

static inline ssize_t generic_write_sync(struct kiocb *iocb, ssize_t count)
{
	if (iocb_is_dsync(iocb)) {
		int ret = vfs_fsync_range(iocb->ki_filp,
				iocb->ki_pos - count, iocb->ki_pos - 1,
				(iocb->ki_flags & IOCB_SYNC) ? 0 : 1);
		if (ret)
			return ret;
	} else if (iocb->ki_flags & IOCB_DONTCACHE) {
		struct address_space *mapping = iocb->ki_filp->f_mapping;

		filemap_fdatawrite_range_kick(mapping, iocb->ki_pos - count,
					      iocb->ki_pos - 1);
	}

	return count;
}

참고 자료