# Buffered write는 복사한 byte를 왜 다시 돌려놓을까요?

사용자 buffer에서 page cache로 데이터를 복사했는데 filesystem이 그 전부를 받아들이지 않을 수 있습니다. 사용자 주소에 fault가 났거나, write-end 처리가 부분 복사를 거절했거나, 더 작은 단위로 다시 시도해야 하는 경우입니다. buffered write의 진행량은 단순한 메모리 복사량이 아닙니다.

upstream v6.18의 `generic_perform_write()`는 준비, 복사, 확정이라는 세 단계를 반복합니다. 실제 block 배치와 최종 저장은 filesystem이 맡지만, 공통 경로도 lock을 가진 상태의 사용자 접근과 short write를 세심하게 처리해야 합니다.

## write_iter를 쓴다고 direct I/O인 것은 아닙니다

일반적인 write syscall은 VFS를 거쳐 filesystem의 write operation에 도달합니다. `write_iter` interface를 쓰는 filesystem이라도 buffered write일 수 있습니다. `kiocb`는 file, position과 I/O flag를 담고 `iov_iter`는 사용자 데이터의 남은 범위를 표현합니다.

generic wrapper는 크기와 쓰기 가능 여부 등을 검사하고 inode의 변경을 직렬화합니다. `__generic_file_write_iter()`는 setuid 같은 privilege 제거와 timestamp 갱신을 처리하고 buffered 또는 direct 경로로 내려갑니다. 이 내부 helper가 `O_SYNC`의 최종 동기화까지 모두 수행하지는 않습니다. 상위 wrapper는 inode lock을 놓은 뒤 `generic_write_sync()`를 호출하여 불필요한 lock 보유 중 대기를 피합니다. [v6.18 mm/filemap.c](https://github.com/torvalds/linux/blob/v6.18/mm/filemap.c)

버전에 따라 task의 backing-device 관련 필드를 임시로 설정하던 코드나 callback 인자가 다르게 보일 수 있습니다. 이런 구현 세부를 현재의 필수 API로 외우기보다 확인하는 release의 함수 signature와 wrapper 계약을 함께 읽는 편이 정확합니다.

아래는 [v6.18의 두 write wrapper 전체](https://github.com/torvalds/linux/blob/v6.18/mm/filemap.c)입니다. 바깥 함수가 inode lock 아래에서 `generic_write_checks()`를 수행하고 양수인 경우에만 실제 write로 내려갑니다. 안쪽 함수는 privilege 제거와 timestamp 갱신 실패를 그대로 반환합니다. current 코드에는 예전 `current->backing_dev_info`를 설정·해제하던 줄이 없습니다.

<!-- include-code:code/generic_file_write_iter_v6_18.c -->

<!-- include-code:code/__generic_file_write_iter_v6_18.c -->

안쪽 전체 정의에 있는 direct-I/O 분기도 자르지 않았습니다. 남은 데이터가 있고 DAX가 아닌 경우 buffered fallback을 시도할 수 있지만, direct write가 error이거나 남은 데이터가 없거나 DAX이면 같은 fallback을 수행하지 않습니다. 이 분기는 buffered loop가 모든 filesystem의 direct-I/O implementation도 대신한다는 뜻은 아닙니다.

## write_begin은 복사 가능한 folio를 준비합니다

`write_begin`은 대상 offset에 대응하는 folio와 filesystem private state를 준비합니다. cache miss라면 allocation이 필요할 수 있고, 파일의 일부 block만 덮어쓴다면 덮어쓰지 않을 byte를 보존하기 위한 read도 필요할 수 있습니다. 새 block의 zeroing이나 allocation 실패 정리도 filesystem의 책임에 들어갑니다.

buffer 기반 경로에서는 cache folio를 locked 상태로 얻고, write와 stable 요구에 맞는 준비를 거쳐 block mapping을 구성합니다. 과거 `grab_cache_page_write_begin()`의 page·NULL 중심 API와 현재 folio·error pointer 중심 API를 섞어서는 안 됩니다. 같은 목적이라도 호출자가 확인해야 할 실패 표현이 달라집니다.

준비에 성공했다고 요청한 최대 chunk가 모두 그 folio 안에 들어간다는 보장은 없습니다. v6.18 공통 loop는 `offset_in_folio()`를 구한 다음 실제 folio 끝까지로 복사 길이를 다시 제한합니다. mapping이 큰 folio를 허용해도 반환된 folio의 크기를 직접 확인합니다.

다음은 [현재 `block_write_begin()` 전체](https://github.com/torvalds/linux/blob/v6.18/fs/buffer.c)입니다. `FGP_WRITEBEGIN`은 LOCK·WRITE·CREAT·STABLE을 묶습니다. 여기의 WRITE는 쓰기 의도에 맞는 cache 처리이지 file permission 검사나 writable PTE 설치를 의미하지 않습니다. folio lookup의 error pointer는 그대로 `PTR_ERR()`로 반환합니다. 이 이른 실패에서는 output pointer를 갱신하는 줄에 도달하지 않으므로 caller는 status를 먼저 검사해야 합니다. block 준비가 실패한 경로는 얻은 folio를 unlock·put하고 NULL을 output으로 넘깁니다.

<!-- include-code:code/block_write_begin_v6_18.c -->

예전 page 기반 interface는 다음 [v6.0 `grab_cache_page_write_begin()` 전체](https://github.com/torvalds/linux/blob/v6.0/mm/folio-compat.c)와 비교할 수 있습니다. 이 API의 NULL 실패와 현재 folio API의 error pointer를 한 코드의 혼합 조건으로 사용하지 않습니다. cache 정리만으로 filesystem이 이미 할당한 block의 실패 복구까지 모두 끝나는 것도 아닙니다.

<!-- include-code:code/grab_cache_page_write_begin_v6_0.c -->

## atomic copy는 사용자 buffer가 반드시 유효하다는 뜻이 아닙니다

folio와 filesystem 관련 lock을 가진 채 사용자 주소의 일반적인 fault를 처리하면 임의의 filesystem 경로로 재진입할 수 있습니다. lock 순환으로 이어질 수 있으므로 공통 write loop는 `copy_folio_from_iter_atomic()`을 사용합니다.

이 선택은 사용자 buffer의 모든 byte를 반드시 복사한다는 보장이 아닙니다. 잠재적인 fault를 그 자리에서 모두 해결하지 않기 때문에 short copy나 0-byte copy가 가능합니다. architecture에 필요한 dcache flush도 이 주변에서 처리됩니다. x86만 보고 공통 코드의 cache maintenance를 무의미한 작업이라고 지워 읽어서는 안 됩니다.

아래는 구현코드가 아닌 호출관계입니다.

```text
generic_perform_write()
  -> balance_dirty_pages_ratelimited()
  -> a_ops->write_begin()
  -> copy_folio_from_iter_atomic()
  -> a_ops->write_end()
  -> reconcile iterator progress
  -> retry or advance position
```

이 순서는 v6.18 기준입니다. dirty balancing이 매 iteration의 `write_begin` 앞에 있다는 점은 오래된 구현의 위치와 구별해야 합니다. 최초 호출 직전 하나의 folio가 방금 dirty였어야만 작동하는 함수라는 뜻도 아닙니다.

아래는 [v6.18 `generic_perform_write()` 전체](https://github.com/torvalds/linux/blob/v6.18/mm/filemap.c#L4227-L4318)입니다. fatal signal, write-begin 오류, atomic copy의 short result, write-end 반환과 iterator 복구, chunk 축소, fault-in 재시도와 최종 position 갱신을 한 함수 안에서 볼 수 있습니다. `iocb->ki_pos`는 이 함수가 양수 진행량을 반환하기 전에 갱신합니다. 오래된 caller의 별도 position 증가를 여기에도 덧붙이면 두 번 전진시킬 수 있습니다.

<!-- include-code:code/generic_perform_write_v6_18.c -->

## write_end가 인정한 양만 진행량이 됩니다

`write_end`에는 요청한 byte 수와 실제 복사한 byte 수가 각각 전달됩니다. 반환값은 filesystem이 받아들인 진행량 또는 error입니다. 공통 loop는 `status != copied`이면 iterator를 되돌려 복사했지만 확정하지 못한 범위를 다시 남깁니다.

예를 들어 3000 byte를 복사했지만 write-end가 1000 byte만 받아들였다면, 다음 작업이 3000 byte 뒤에서 시작해서는 안 됩니다. 사용자가 요구한 stream 중 2000 byte를 건너뛰게 되기 때문입니다. 반대로 단순히 전체 iterator를 처음으로 되돌리면 이미 인정된 byte를 중복 처리하게 됩니다.

0 반환도 별도 취급합니다. v6.18은 큰 chunk를 줄일 수 있으며, 일부를 복사했지만 전부 거절된 상황에서는 그 복사량에 맞춰 재시도합니다. 아무것도 복사하지 못했다면 folio가 unlock된 뒤 사용자 범위를 미리 fault-in하여 진행 가능성을 확인합니다. 여전히 전혀 읽을 수 없으면 `-EFAULT`로 끝납니다. lock을 가진 채 같은 atomic copy만 무한 반복하지 않습니다.

버전 차이를 직접 보려면 다음 [v6.0 전체 loop](https://github.com/torvalds/linux/blob/v6.0/mm/filemap.c)를 비교할 수 있습니다. 이 버전은 page 단위 길이와 callback 인자를 사용하고, write-begin 전에 사용자 범위를 fault-in하며 진행 뒤에 dirty balancing을 합니다. current의 large-folio chunk, zero-copy 뒤 fault-in 위치와 동일한 구현이라고 부르지 않습니다.

<!-- include-code:code/generic_perform_write_v6_0.c -->

## dirty 확정과 저장 완료를 구별합니다

buffer 기반의 `block_write_end()`와 `block_commit_write()`는 수정한 buffer를 uptodate·dirty로 만들고, 필요한 경우 folio 전체 상태를 올립니다. `generic_write_end()`는 파일 크기 갱신 같은 공통 후처리도 담당합니다. 이때 cache와 inode가 dirty 검색에 잡힐 수 있어야 합니다. 이것이 device에 I/O를 제출하고 기다린 결과는 아닙니다. [v6.18 fs/buffer.c](https://github.com/torvalds/linux/blob/v6.18/fs/buffer.c)

`block_write_end()`는 short copy인데 folio가 아직 uptodate가 아니면 수락량을 0으로 만들 수 있습니다. 부분 block에 복사한 내용을 다음 read가 덮어쓸 수 있기 때문입니다. 아직 초기화되지 않은 새 buffer 범위를 zero하는 후처리도 필요합니다. 이 함수 혼자 folio lock을 놓지는 않습니다.

<!-- include-code:code/block_write_end_v6_18.c -->

상위 `generic_write_end()`는 `i_rwsem`으로 size 변경이 직렬화된 상태에서, folio lock을 놓기 전에 `i_size`를 갱신합니다. writeout이 예전 size를 보고 방금 쓴 부분을 EOF 밖으로 zero하지 못하게 하는 순서입니다. 이후 folio를 unlock·put하고, 필요하면 파일 확장 처리와 inode dirty 표시를 이어갑니다. inode dirty 처리를 folio lock 밖으로 미루는 데에는 journal transaction과의 lock ordering도 관계됩니다.

<!-- include-code:code/generic_write_end_v6_18.c -->

마지막 두 helper도 [v6.18 전체 정의](https://github.com/torvalds/linux/blob/v6.18/fs/buffer.c)입니다. 첫 helper가 겹치는 buffer를 dirty·uptodate로 만들고, 두 번째 helper가 처음 dirty인 buffer에서 folio와 inode의 dirty 추적으로 연결합니다. 이미 dirty인 경우의 barrier와 재검사는 새 수정과 concurrent writeback 사이의 ordering을 지키려는 것입니다.

<!-- include-code:code/block_commit_write_v6_18.c -->

<!-- include-code:code/mark_buffer_dirty_v6_18.c -->

loop는 성공한 양만큼 position과 누적 `written`을 늘립니다. 뒤에서 error가 발생해도 이미 쓴 byte가 있으면 그 양을 돌려줄 수 있습니다. 따라서 호출자는 양수 반환이 요청 전체의 완료인지 확인해야 합니다. 동기 write 옵션은 후속 sync에서 error를 발견할 수도 있으므로 마지막 반환값까지 확인해야 합니다.

복사한 양, filesystem이 수락한 양, 저장이 끝난 양을 나누어 읽으면 partial write의 의미가 훨씬 선명해집니다. 같은 양수 반환도 어느 계층에서 무엇을 완료한 결과인지 확인해야 합니다.

## 참고 자료

- Lorenzo Stoakes, *The Linux Memory Manager*, Early Access 2025, Chapter 10, File Write Dirty Tracking.
- [Linux v6.18 mm/filemap.c](https://github.com/torvalds/linux/blob/v6.18/mm/filemap.c): `generic_perform_write()`와 generic write wrapper 전체 구현.
- [Linux v6.18 fs/buffer.c](https://github.com/torvalds/linux/blob/v6.18/fs/buffer.c): buffer 기반 write 준비와 write-end.
- [Linux v6.18 include/linux/fs.h](https://github.com/torvalds/linux/blob/v6.18/include/linux/fs.h): `generic_write_sync()` 및 callback interface.
