# Writeback을 마친 inode는 어느 목록으로 돌아갈까요?

flusher가 inode 하나를 처리했다고 그 inode를 무조건 clean 목록으로 보내지는 않습니다. quota를 다 썼을 수도 있고, filesystem이 일부 folio의 제출을 거절했을 수도 있으며, I/O를 만드는 동안 metadata가 다시 dirty가 되었을 수도 있습니다.

이후의 배치는 단순 cleanup이 아닙니다. 어떤 inode에 곧바로 다음 기회를 줄지, 어떤 것은 잠시 미룰지, 어떤 것은 dirty 목록에서 빼도 되는지를 결정합니다. 이 정책은 inode의 수명과 lock 순서에도 맞물립니다.

## superblock을 보호한 범위만 처리합니다

`writeback_sb_inodes()`는 특정 superblock을 처리합니다. caller가 unmount를 막는 보호를 확보했으므로 다른 superblock의 inode를 우연히 같이 처리하면 안 됩니다.

명시적으로 `work->sb`가 지정된 요청에서는 다른 superblock의 inode를 dirty 쪽으로 돌려보내고 계속 찾습니다. 여러 filesystem을 처리하는 일반 요청이라면 다른 superblock 묶음을 만났을 때 caller로 돌아가 현재 보호를 놓고 다음 superblock의 보호를 얻습니다. 앞서 queue 단계에서 같은 superblock끼리 모으는 이유가 여기서 드러납니다.

여러 superblock을 도는 상위 경로는 즉시 lock을 얻지 못할 때 해당 inode를 미루어 다른 진행을 허용할 수 있습니다. 이것은 모든 inode를 기다리지 않고 건너뛴다는 일반 규칙이 아니라 flusher의 해당 trylock 경로에 대한 선택입니다. [v6.18 fs/fs-writeback.c](https://github.com/torvalds/linux/blob/v6.18/fs/fs-writeback.c)

아래는 v6.18 `__writeback_inodes_wb()` 전체입니다. 과거 이름인 `trylock_super()` 대신 현재는 `super_trylock_shared()`를 호출합니다. 성공하면 unmount 방지용 shared `s_umount`를 가진 상태에서 inode 묶음을 처리하고 `up_read()`로 놓습니다. 실패하면 즉시 다시 검사하는 `b_more_io`로 보내지 않고 `b_dirty` 쪽으로 미룹니다. 같은 superblock lock을 busy retry하는 일을 피하기 위해서입니다.

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

이 함수의 `wrote`에는 page뿐 아니라 clean으로 정리한 inode의 진전도 합산됩니다. 한 page라도 저장되었다고 판단하는 엄격한 단위가 아닙니다. `HZ / 10` 검사도 진전 후 상위 loop로 돌아갈 기회를 만드는 조건이지, 한 번의 I/O나 함수 실행이 반드시 0.1초 안에 끝나는 hard deadline은 아닙니다.

## I_SYNC를 기다린 뒤 pointer를 재사용하지 않습니다

inode의 `i_lock` 아래에서 `I_NEW`, `I_FREEING`, `I_WILL_FREE`를 확인하고 현재 작업에 적절하지 않으면 미룹니다. 이미 `I_SYNC`가 설정되어 다른 inode writeback이 진행 중인 경우도 있습니다.

`WB_SYNC_NONE`이면 `b_more_io`로 옮겨 다른 inode를 먼저 처리할 수 있습니다. integrity 작업이라면 기다려야 합니다. 이때 사용하는 `inode_sleep_on_writeback()`은 `i_lock`을 놓으며, caller가 별도 inode 참조를 가지고 있다는 보장이 없습니다. 기다리는 동안 inode가 사라질 수 있어 반환 후 기존 pointer로 그대로 이어가지 않고 목록부터 다시 시작합니다.

이것은 참조를 유지하는 `inode_wait_for_writeback()`의 계약과 다릅니다. helper 이름이 비슷하다는 이유로 lock·ref 조건을 교환해서 사용할 수는 없습니다. 정상 처리에서는 `I_SYNC`를 먼저 세워 eviction이 해당 inode를 해제하지 못하도록 하고 긴 I/O 경로로 내려갑니다.

다음은 `inode_sleep_on_writeback()` 전체입니다. waiter를 준비한 뒤 `i_lock` 아래에서 bit를 확인하고 lock을 놓습니다. 실제 sleep이 필요 없는 경우도 있지만 caller에게 inode 참조를 새로 주지는 않습니다. 깨어났다는 사실만으로 pointer를 다시 dereference하거나 folio I/O가 성공했다고 판단하지 않습니다.

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

## 긴 I/O 중에는 목록 spinlock을 놓습니다

함수 진입 시에는 `wb->list_lock`을 가지고 있지만 실제 inode I/O를 수행하는 동안 이 lock을 계속 들고 있지 않습니다. per-inode control을 준비하고 chunk 크기를 정한 뒤 `__writeback_single_inode()`로 내려갑니다.

`writeback_control`은 work의 sync·background·cyclic 조건을 이어받고 inode 전체 byte 범위를 지정합니다. `nr_to_write`는 이 차례의 chunk이며, `pages_skipped`는 0으로 시작합니다. 끝나면 차감한 page 수와 건너뛴 양을 구별해 진전을 집계합니다. 이 수는 device에서 성공적으로 영구 저장한 byte를 직접 세는 counter가 아닙니다.

cgroup writeback을 사용하는 경우 처리 도중 inode의 소속 writeback 객체가 바뀔 수 있습니다. 후처리에서는 다시 inode의 wb를 얻고 그 목록 lock을 잡아야 합니다. 처음의 wb pointer에 영구히 고정되어 있다고 가정하면 다른 목록을 잘못 건드릴 수 있습니다.

아래는 [v6.18 `writeback_sb_inodes()` 전체](https://github.com/torvalds/linux/blob/v6.18/fs/fs-writeback.c#L1917-L2065)입니다. 초기 검사·대기·실제 호출·후처리를 하나의 scope로 볼 수 있습니다. 함수의 진입과 반환에는 caller의 `wb->list_lock` 보유가 연결되지만, 내부의 긴 I/O와 reschedule 중까지 그 spinlock을 유지하지 않습니다.

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

`write_chunk - wbc.nr_to_write`는 work quota에서 차감합니다. 진전량에는 그 차이에서 `pages_skipped`를 빼고 0 아래를 잘라 사용합니다. inode가 더는 `I_DIRTY_ALL`이 아니면 별도로 1을 더하므로 반환값은 pages와 inodes를 함께 세는 진행 지표입니다. 이 caller는 `__writeback_single_inode()`의 반환 errno를 곧바로 자기 반환값으로 전달하지 않습니다. I/O error 기록과 나중 sync에서의 보고 경로를 함께 보아야 합니다.

chunk를 정하는 전체 helper는 다음과 같습니다. `WB_SYNC_ALL` 또는 tagged 작업은 `LONG_MAX`를 사용합니다. 일반 작업은 평균 bandwidth의 절반, dirty limit의 일부, work 잔여량을 고려하고 `MIN_WRITEBACK_PAGES` 단위로 조정합니다. 조정 때문에 결과가 단순한 byte quota나 모든 inode에 동일한 고정 page 수는 아닙니다.

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

## 재등록은 남은 작업의 이유를 구별합니다

`requeue_inode()`는 먼저 freeing 상태를 확인합니다. integrity 또는 tagged 작업 뒤에도 inode가 dirty이면 새 시각을 기록하여 같은 sync 작업이 끊임없이 다시 잡지 않게 합니다.

`pages_skipped`가 있으면 이번에는 진행하기 어려웠다는 뜻일 수 있습니다. `I_DIRTY_ALL`이 남으면 dirty 목록으로 미루고, clean인 예외에는 `inode_cgwb_move_to_attached()`를 호출한 뒤 이 경로를 끝냅니다. Cgroup wb에서는 `b_attached`로 옮기지만, root wb이거나 cgroup writeback이 비활성인 경로에서는 writeback 목록에서 제거합니다. Skipped page가 있다는 사실만으로 inode 자체가 반드시 dirty라고 가정하지 않습니다.

XArray의 DIRTY mark가 남은 경우 `b_more_io`로 보내는 조건은 두 가지입니다. `nr_to_write <= 0`으로 chunk를 소진했으며, 동시에 `!inode_dirtied_after(inode, dirtied_before)`로 현재 cutoff보다 뒤에 dirty된 inode가 아니어야 합니다. Quota만 소진했다고 곧바로 이 목록으로 돌아가는 것은 아닙니다.

앞에서 sync 또는 tagged 작업 뒤 `dirtied_when`을 갱신했다면 이 cutoff 검사에서 제외될 수 있습니다. 그런 inode는 chunk를 소진했더라도 `b_dirty` 쪽으로 미뤄질 수 있습니다. Quota 미소진과 cutoff 이후 redirty를 모두 고려해야 같은 sync가 inode를 끝없이 다시 처리하는 일을 막을 수 있습니다.

XArray DIRTY mark는 없는데 inode 자체가 dirty일 수도 있습니다. delayed allocation이나 I/O 관련 metadata 갱신이 그 예입니다. 이 경우에도 metadata를 처리할 기회가 필요합니다. timestamp만 남았다면 별도 `b_dirty_time`으로 갈 수 있고, 정말 clean일 때에만 일반 writeback 목록에서 제거합니다.

`redirty_tail_locked()`의 tail은 처리 순서상 뒤로 미룬다는 의미로 읽어야 합니다. 실제 linked list에서는 head로 이동하는 코드가 보일 수 있습니다. 뒤에서부터 소비하기 때문입니다. 이때 시간 정렬이 깨지지 않도록 `dirtied_when`을 조정하는 조건도 함께 있습니다.

두 redirty helper와 `requeue_io()`의 전체는 다음과 같습니다. 현재 `redirty_tail_locked()`는 `I_SYNC_QUEUED`를 먼저 지우며, `I_FREEING`이면 dirty 목록으로 옮기는 대신 연결을 제거하고 dirty-I/O 통계를 정리합니다. freeing 상태를 검사하는 모든 경로가 같은 후처리를 한다고 일반화하지 않습니다. 반면 `requeue_io()`는 추가 처리를 위해 `b_more_io`로 이동합니다.

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

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

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

다음은 Linux v6.18의 `requeue_inode()` 함수 전체입니다. 함수 내부의 분기와 주석을 생략하지 않았으며, Linux kernel의 다른 선언과 helper를 사용하는 code이므로 이 파일만 따로 compile하는 예제는 아닙니다. 원본 파일의 라이선스는 GPL-2.0-only입니다.

[함수 전체 파일](requeue_inode_v6_18.c) · [원본 source](https://github.com/torvalds/linux/blob/v6.18/fs/fs-writeback.c#L1630-L1694) · [라이선스](https://github.com/torvalds/linux/blob/v6.18/LICENSES/preferred/GPL-2.0)

<!-- include-code:requeue_inode_v6_18.c -->

clean 예외에서 사용하는 helper도 config별 전체를 확인할 수 있습니다. 첫 번째는 cgroup writeback 활성 구현이며 root wb에는 `b_attached` 이동을 하지 않습니다. 두 번째는 비활성 구현입니다. 두 경우 모두 필요한 목록 lock과 inode lock을 가진 상태여야 합니다. `I_SYNC_QUEUED`를 지웠다고 inode의 data I/O 완료까지 선언하는 것은 아닙니다.

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

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

## dirty state를 지운 뒤 mark를 다시 보는 이유가 있습니다

`__writeback_single_inode()`는 `do_writepages()`를 호출합니다. `WB_SYNC_ALL`이면서 전체 sync의 별도 대기 경로가 아닌 경우에는 data 완료를 기다린 뒤 metadata 처리로 넘어갑니다. I/O 완료에서 metadata를 바꾸는 filesystem도 고려해야 합니다.

이후 `i_lock` 아래에서 inode dirty state를 수집하고 지웁니다. 하지만 여기서 종료하면 동시에 dirty로 바꾸는 task의 빠른 검사가 변경을 놓칠 수 있습니다. 따라서 `smp_mb()` 뒤 XArray의 DIRTY mark를 다시 확인하고 남아 있으면 `I_DIRTY_PAGES`를 복구합니다. 반대편 `__mark_inode_dirty()`의 barrier와 짝을 이뤄, 어느 쪽이든 새 변경을 보도록 하는 구조입니다.

data dirty만 있었을 때에는 inode metadata write callback을 불필요하게 호출하지 않습니다. 그 밖의 metadata dirty가 남았다면 `write_inode()`를 수행하고 먼저 발생한 error를 보존합니다. data scan의 종료와 metadata의 종료가 별도 단계라는 점을 다시 확인할 수 있습니다.

다음은 현재 `__writeback_single_inode()` 전체입니다. `I_SYNC`의 설정과 해제는 caller 책임이며 이 함수가 inode를 임의로 일반 목록에서 제거하지는 않습니다. dirty-time 만료나 `WB_SYNC_ALL`이면 `mark_inode_dirty_sync()`로 filesystem에 metadata 변경을 알린 뒤 dirty state를 수집합니다. dirty state를 지우는 위치가 writepages와 timestamp 처리 뒤인 이유입니다.

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

mapping의 DIRTY mark가 없다면 netfs pin 해제 관련 상태도 처리할 수 있습니다. `I_PINNING_NETFS_WB`를 내리고 `wbc.unpinned_netfs_wb`를 callback에 전달한 뒤 마지막에 다시 false로 정리합니다. 따라서 `dirty & ~I_DIRTY_PAGES`는 단순한 data page 유무 검사보다 넓습니다. metadata callback의 error가 있어도 이미 앞선 error가 있으면 그것을 보존합니다. dirty flag를 지운 사실 자체는 저장 성공 증명이 아닙니다.

## 목록에서 빠져도 device I/O가 남을 수 있습니다

후처리는 `requeue_inode()`로 다음 위치를 정하고 `inode_sync_complete()`로 `I_SYNC`를 지워 waiter를 깨웁니다. `I_SYNC`는 이 inode의 writeback 처리를 직렬화하는 상태이지 각 folio의 모든 device I/O를 표현하는 bit가 아닙니다.

목록이 비면 `WB_has_dirty_io`와 active bandwidth 관련 통계를 정리합니다. 그러나 data-integrity caller는 이미 제출된 folio writeback을 별도로 기다려야 할 수 있습니다. flusher는 약 1/10초의 진행 구간 뒤 상위 loop로 돌아가 threshold나 새 요청도 다시 확인합니다. 이 시간은 모든 I/O를 그 안에 끝낸다는 latency 보장이 아니라 긴 독점을 피하기 위한 판단 주기입니다.

아래 `inode_sync_complete()` 전체는 `i_lock` 아래에서 `I_SYNC`를 지우고, clean·unused inode의 LRU 처리를 한 뒤 bit waiter를 깨웁니다. 이 wakeup을 모든 folio의 writeback flag 해제와 교환해서 사용할 수는 없습니다.

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

## 참고 자료

- Lorenzo Stoakes, *The Linux Memory Manager*, Early Access 2025, Chapter 10, inode writeback 및 requeue 설명.
- [Linux v6.18 fs/fs-writeback.c](https://github.com/torvalds/linux/blob/v6.18/fs/fs-writeback.c): `writeback_sb_inodes()`, inode wait, `requeue_inode()`, `__writeback_single_inode()` 전체 경로.
