-
[xv6] Log프로젝트/운영체제 만들기 2023. 7. 25. 17:33
글의 참고
- https://github.com/mit-pdos/xv6-public/tree/master
- xv6 - DRAFT as of September 4, 2018
글의 전제
- 밑줄로 작성된 글은 강조 표시를 의미한다.
- 그림 출처는 항시 그림 아래에 표시했다.
글의 내용
: 모든 기술에는 반드시 필요성이 존재한다. 그런데, FAT12/16/32를 공부할 때는, `충돌`에 대한 고려를 전혀하지 않았는데, 유닉스 계열의 파일 시스템은 초기부터 이 부분에 대한 고려가 있는 것 같다. `log` 파트는 `xv6`를 공부하면서 제일 이해가 가지 않았던 파트이다. 그래서 `log` 파트는 소스 분석보다는 먼저 문서를 통해 해당 기술에 대한 필요성을 인식해야 할 것 같다.
One of the most interesting problems in file system design is crash recovery. The problem arises because many file system operations involve `multiple writes to the disk`, and a crash after a subset of the writes may leave the on-disk file system in an inconsistent state. For example, suppose a crash occurs during file truncation (setting the length of a file to zero and freeing its content blocks). Depending on the order of the disk writes, the crash may either leave an inode with a reference to a content block that is marked free, or it may leave an allocated but unreferenced content block.
The latter is relatively benign, but an inode that refers to a freed block is likely to cause serious problems after a reboot. After reboot, the kernel might allocate that block to another file, and now we have two different files pointing unintentionally to the same block. If xv6 supported multiple users, this situation could be a security problem, since the old file’s owner would be able to read and write blocks in the new file, owned by a different user.
- 참고 : xv6 - DRAFT as of September 4, 2018 [Logging layer]: 먼저 `xv6`에서 말하는 `crash recovery`란, 아래에서 언급하고 있지만, 한 개의 디스크 블락에 여러 개의 쓰기 요청이 동시 다발적으로 들어올 때, 발생한다. 예를 들어, `trucation` 동작은 운영 체제마다 다를 수 있다. 그러나, 대개는 파일의 길이를 0으로 한 뒤, 데이터 블락을 FREE로 표시한다. 혹은 이 반대일 수도 있다. 그런데, 이 과정에서 갑자기 `충돌`이 발생하면, inode가 `FREE`라고 표시된 데이터 블락을 계속 참조하고 있을 수도 있고, 혹은, 실제로는 참조하고 있지 않은 데이터 블록을 `IN-USE`라고 표시될 수도 있다.
: 후자의 경우는 그래도 양호하다. 왜냐면, 저 데이터 블록은 절대 사용될 일이 없기 때문이다(`사용 중`으로 표시되어 있기 때문에). 이런 경우를 `메모리 릭`이 발생했다고 한다. 즉, 실제로는 사용되고 있지 않지만, `사용 중`이라는 표시 때문에 해당 데이터 블락을 할당하지 못하는 경우다. 그런데, 중요한 건 전자의 상황이다. 즉, 외부에 `FREE`라고 표시된 데이터 블락이 실제로는 다른 inode가 이미 참조 중인 경우가 있을 수 있다. 예를 들어, 크래시가 발생했다. 운영 체제는 부팅을 진행하면서, 파일 시스템을 읽어들인다. 그리고 새로운 파일에 만든다. 그런데, 이 파일에 `FREE`라고 표시된 블락을 할당했다. 그런데, 보니깐 이 블락은 다른 곳에서 이미 참조가 되고 있었다. `충돌`이 발생했다.
Xv6 solves the problem of crashes during file system operations with a simple form of logging. An xv6 system call does not directly write the on-disk file system data structures. Instead, it places a description of all the disk writes it wishes to make in a `log` on the disk. Once the system call has logged all of its writes, it writes a special `commit` record to the disk indicating that the log contains a `complete operation`. At that point the system call copies the writes to the on-disk file system data structures. After those writes have completed, the system call erases the log on disk.
If the system should crash and reboot, the file system code recovers from the crash as follows, before running any processes. If the log is marked as containing a `complete operation`, then the recovery code copies the writes to where they belong in the on-disk file system. If the log is not marked as containing a complete operation, the recovery code ignores the log. The recovery code finishes by erasing the log.
Why does xv6’s log solve the problem of crashes during file system operations? If the crash occurs before the operation commits, then the log on disk will not be marked as complete, the recovery code will ignore it, and the state of the disk will be as if the operation had not even started. If the crash occurs after the operation commits, then recovery will replay all of the operation’s writes, perhaps repeating them if the operation had started to write them to the on-disk data structure. In either case, the log makes operations atomic with respect to crashes: after recovery, either all of the operation’s writes appear on the disk, or none of them appear.
- 참고 : xv6 - DRAFT as of September 4, 2018 [Logging layer]: `xv6`는 `로그 레이어`를 도입해서 파일 시스템 관련 동작 중 `충돌`이 발생하는 것을 막는다. 여기서 파일 시스템 동작을 마무리하고 나서 충돌을 해결한다거나, 파일 시스템 관련 작업을 하기전에 충돌을 먼저 해결하는게 아니라, 파일 시스템 작업을 하는 과정 중간에 충돌 문제를 해결한다고 한다. 이건 나중에 뒷쪽에서 다시 얘기한다.
: `xv6`에서는 파일 시스템에 직접 데이터를 쓰지 않는다. 즉, 중간에 버퍼 레이어가 존재한다. 이 레이어가 바로 `로그 레이어`다. 즉, 데이터 영역에 쓸 내용들을 먼저 `로그` 영역 쓴다. `xv6`에서는 파일 시스템 관련해서 `commit` 이라는 특별한 명령어가 있는데, 이 명령어는 `로그` 영역에 있는 데이터들을 `데이터` 영역으로 복사하는 것을 의미한다. 즉, `commit` 명령어가 실행되는 시점이 실제로 데이터가 적용되는 시점이다. `로그` 영역에 있는 데이터가 모두 `데이터` 영역에 써지면, `로그` 영역의 데이터를 모두 제거한다.
: 여기서 `complete operation` 이라는 용어가 나온다. `complete operation`는 `로그` 영역에 이미 완료된 액션들을 의미한다. 예를 들어, `xv6`는 디스크에 데이터를 직접 쓰지 않는다고 했다. 그래서 데이터 추가 및 수정 동작들이 `로그` 영역에 수행될 것이다. 앞에서 언급한 동작들이 모두 `complete operation` 이다. 즉, 말 그대로 `로그` 영역에 행해진 `완료(처리 및 수행)된 동작`을 의미한다.
: `커밋`이 호출되기 전에 `충돌`이 발생하면, `xv6`는 시스템을 복구 하지 못한다. 만약, `커밋`이 호출된 후에 `충돌`이 발생하면, `xv6`는 시스템을 복구할 수 있다.
The log resides at a known fixed location, specified in the superblock. It consists of a header block followed by a sequence of updated block copies (‘‘logged blocks’’). The header block contains an array of sector numbers, one for each of the logged blocks, and the count of log blocks. The count in the header block on disk is either zero, indicating that there is no transaction in the log, or non-zero, indicating that the log contains a complete committed transaction with the indicated number of logged blocks. Xv6 writes the header block when a transaction commits, but not before, and sets the count to zero after copying the logged blocks to the file system. Thus a crash midway through a transaction will result in a count of zero in the log’s header block; a crash after a commit will result in a non-zero count.
Each system call’s code indicates the start and end of the sequence of writes that must be atomic with respect to crashes. To allow concurrent execution of file system operations by different processes, the logging system can accumulate the writes of multiple system calls into one transaction. Thus a single commit may involve the writes of multiple complete system calls. To avoid splitting a system call across transactions, the logging system only commits when no file system system calls are underway.
- 참고 : xv6 - DRAFT as of September 4, 2018 [Logging design]: `로그` 영역은 고정된 위치와 고정된 사이즈를 갖는다. `로그` 영역에 대한 메타 정보는 `superblock`에 명시되어 있다. 어떻게 보면, `로그 레이어`에서 가장 중요한 자료 구조는 `로그 헤더`다. 이 헤더는 수정된 블락들의 개수(카운트)와 수정된 각각의 블락들 번호가 저장되어 있다. 만약에, 디스크에서 `로그` 블락을 읽어서 `로그 헤더`에 로드했다고 가정하자. 그런데, 디스크에서 읽어온 `로그 헤더`의 카운트, 즉, 수정된 블락들의 개수가 0일 경우, `로그` 영역에 트랜잭션이 존재하지 않는다고 가정한다. 즉, 복구할 게 없다고 판단한다. 만약, 카운트가 0이 아니면, `로그` 영역에 수정된 로그 블락들이 존재한다는 것을 의미하므로, `로그` 영역에 수정된 로그 블락들을 읽어서, 실제 데이터 영역에 쓴다. 이게 `xv6`에서의 복구 과정이다.
: `xv6`는 `commit` 호출이 될 때만, `로그 헤더`를 디스크의 `로그` 영역에 쓴다. 즉, `commit`이 호출되기 전까지는 `로그 헤더`의 내용을 디스크에 쓰지 않는다. `commit`이 호출되면, `로그 헤더` 및 `버퍼 캐시`에 있던 내용들이 모두 디스크의 `로그` 영역에 쓴다. 그리고 `로그 영역`에 있는 내용들을 실제 데이터 영역에 모두 복사한다. 그리고 나서, `로그 헤더`의 카운트를 0으로 설정한다. 즉, `로그 영역`에 있는 데이터가 `실제 데이터 영역` 써져야 `로그 헤더` 카운트가 0이 된다.
: 이제부터는 내 개인적인 생각이다. `xv6`의 에서 복구 메커니즘을 이루어지기 위해서는 일단 `수정된 데이터 블락 개수`와 `수정된 데이터의 내용`이 `로그 영역`에 써져 있어야 한다. `로그 영역`에만 일단 써져있다면, 로그 시스템이 초기화 시점에 디스크의 로그 영역에서 충돌 이전에 기록된 로그 데이터를 읽어서 실제 데이터 영역에 쓴다. 그러나, 충돌전에 `인-메모리`에 있던 데이터를 `로그 영역`에 쓰지 않았다면, 즉, `커밋`을 하지 않았다면, 복구는 불가능한 것 같다.
: `xv6` 에서 `충돌`을 막기 위해 파일 시스템 관련 시스템 콜은 반드시 원자적으로 호출된다. 그런데, 멀티 프로세서 시스템에서 시스템 콜 마다 원자적으로 호출될 경우, 시스템 콜 하나가 디스크 쓰기 하나에 대응될 가능성이 있다. 이럴 경우, 버퍼 캐시 같은 작업들이 소용이 없게 된다. 그래서 `xv6`는 여러 개의 시스템 콜이 하나의 트랜잭션으로 묶일 수 있도록 시스템 콜을 축척하는 구조를 생각해놨다. 이 결과로 하나의 `commit` 이 발생하면, 쌓여있던 여러 개의 시스템 콜이 수행될 수 있다. 그런데, 이 구조는 사실 단순하다. 특별히 새로운 구조를 만들 필요없이 현재 처리가 진행 중인 시스템 콜이 없을 경우에만 `commit`을 진행하면 된다.
The idea of committing several transactions together is known as `group commit`. Group commit reduces the number of disk operations because it amortizes the fixed cost of a commit over multiple operations. Group commit also hands the disk system more concurrent writes at the same time, perhaps allowing the disk to write them all during a single disk rotation. Xv6’s IDE driver doesn’t support this kind of `batching`, but xv6’s file system design allows for it.
: 여러 개의 트랜잭션을 한 꺼번에 처리하는 방법을 `그룹 커밋`이라고 한다. `그룹 커밋`은 디스크에 직접 액세스하는 횟수를 줄임으로써, 오버헤드를 줄인다. `xv6`의 `그룹 커밋`은 마치 `batching`과 같다. 즉, 디스크가 싱글 로테이션에 여러 개의 섹터에 쓸 수 있다면, `그룹 커밋`은 분명히 성능상 좋은 이점을 가질 것이다. 그러나, `xv6`의 IDE 드라이버는 이런 기능을 지원하지 않는다. 즉, 하나의 로테이션 당 하나의 동작을 수행해야 한다.
: 최초의 예시했던 `truncate`를 떠올려 보자. `truncate` 작업은 4가지 동작으로 이루어졌다고 가정하자.
0" 데이터 블락을 FREE로 표시한다.
1" 디스크에 수정된 내용을 쓴다.
2" 데이터 블락과의 참조를 해제한다.
3" 디스크에 수정된 내용을 쓴다.: 그런데, `truncate` 중에 갑자기 전원이 Off 됬다. 위의 작업중에 `1번 작업`까지는 진행했다고 치자. 다시 전원이 On 되고, 부트-업이 됬다. 이제 어떻게 될까? 앞에서 언급됬던 데이터 블락은 참조가 해제되지 않아서 이전 파일에서 참조되고 있으면서, 새로운 파일에도 할당이 될 수 있는 상황이다. 이 문제는 2가지 때문에 발생했다. (갑작스럽게 전원이 Off 된 것을 원인으로 보지는 말자)
0" 디스크의 데이터 영역에 직접 데이터를 썼다.
1" 정상적으로 한 트랜잭션안에 마무리 되지 않은 작업들이 디스크 영역에 써졌다.: 즉, 완료되지 않은 작업이 디스크에 직접 써진거다. 운영 체제는 재부팅시에 파일 시스템에서 정보를 읽을 텐데 이게 잘못된 정보라는 것을 알 수 있는 방법이 없다. 왜? 전원이 갑자기 Off 됬는데, 이걸 어떻게 하겠는가? 물론, 재부팅 이유는 하드웨어적으로 기록이 될 것이기 때문에, Powe-Off 라는 것을 알 수 있을 것이다. 그러면, Power-Off 때문에 재부팅되면 무조건 부팅 시에 파일 시스템을 신뢰하면 안된다고 판단해야 할까? 접근이 잘못되었다. `Power-Off`가 초점을 맞추면 이 문제는 복잡해진다. 즉, `Power-Off`가 무조건 발생할 수 밖에 없다는 가정하에 다른 방법을 모색해야 한다. `xv6`에서는 문제의 원인을 위의 2가지로 본 것이다. 그래서 `로그 레이어`는 위의 2가지에 초점이 맞춰져 있다.
0" 디스크의 데이터 영역에 직접 데이터를 쓰지 않는다.
1" 정상적으로 한 트랜잭션안에 마무리 되지 않은 작업들은 버린다.: 로그 레이어는 여기에 `그룹 커밋`이라는 기능을 추가해서 성능마저 개선했다.
: 이제 소스 코드를 분석해보자.
// log.c
....
....
// Contents of the header block, used for both the on-disk header block
// and to keep track in memory of logged block# before commit.
struct logheader {
int n;
int block[LOGSIZE]; // LOGSIZE = (MAXOPBLOCKS * 3) // 30
};
struct log {
struct spinlock lock;
int start;
int size;
int outstanding; // how many FS sys calls are executing.
int committing; // in commit(), please wait.
int dev;
struct logheader lh;
};
struct log log;
void initlog(int dev)
{
if (sizeof(struct logheader) >= BSIZE)
panic("initlog: too big logheader");
struct superblock sb;
initlock(&log.lock, "log");
readsb(dev, &sb);
log.start = sb.logstart;
log.size = sb.nlog;
log.dev = dev;
recover_from_log();
}: `mkfs.c` 파일에 보면, `xv6`의 파일 시스템을 코드들이 작성되어 있다. 여기서 `sb.logstart` 에는 `2`가 할당되고, `sb.nlog` 에는 `30`이 할당된다.
: `xv6`에서 로그 레이어는 `로그 헤더`와 `로그 데이터`로 나누어 볼 수 있다.
로그 헤더" 실제 변경된 데이터 블락의 번호
로그 데이터" 변경된 데이터를 저장: 예를 들어, 4번 섹터를 해야한다고 치자. 그러면, `xv6`에서는 먼저 4번 섹터의 `버퍼 캐시`를 할당한다. `버퍼 캐시`를 할당하면서, 실제 4번 섹터의 내용을 디스크에서 버퍼 캐시로 읽어온다. 그러면, 이제 4번 버퍼 캐시는 `인-메모리` 상태인 것이다. 여기서 이제 4번 버퍼 캐시에 수정이 발생했다. 그런데, `xv6`는 `write-back` 방식을 사용한다. 그래서, 4번 버퍼 캐시에만 변경 내용을 적용하고, 디스크에는 적용하지 않는다. 그리고 나서 시간이 흘러, 4번 버퍼 캐시를 해제했다. 이 때, `log_write`함수와 `brelse` 함수가 순서대로 호출된다. `log_write` 함수는 전달되는 버퍼 캐시의 섹터 번호만 로그 헤더에 저장한다. 왜 섹터 번호만 저장할까? 굳이, 데이터를 들고 있을 필요가 없기 때문이다. 변경된 데이터는 이미 버퍼 캐시에 있고, 실제 변경된 데이터를 참조할 수 있는 인덱스값(섹터 번호)만 알고 있으면, 언제든지 다시 참조할 수 있기 때문이다.
: `recover_from_log` 함수가 `충돌`이 발생했을 때, `로그 영역`에서 실제 `데이터 영역`으로 데이터를 복사하는 함수다. 이 함수에는 2가지가 핵심이다.
0" `로그 영역`에서 `로그 헤더`를 읽어온다.
1" `로그 헤더`의 내용을 기반으로 `로그 영역`의 데이터를 실제 `데이터 영역`에 쓴다.: 이 함수가 실행될 때, `로그 영역`에 아무 데이터도 없다면, 복구는 실패한다. 즉, 이 함수가 실행되기 전에 반드시 `commit`이 되어야 한다.
// log.c
....
....
static void recover_from_log(void)
{
read_head();
install_trans(); // if committed, copy from log to disk
log.lh.n = 0;
write_head(); // clear the log
}: `XXXX_head` 함수는 로그 레이어의 첫 번째 섹터, 즉, `xv6` 기준 `SUPERBLOCK` 바로 뒤에 섹터를 가져와서 로그 레이어의 메타 데이터를 읽거나 쓰는 함수다. 로그 헤더를 사용하는 이유는 알겠는데, 로그 헤더를 굳이 디스크에 저장해서 뭐할까?
// log.c
....
....
// Read the log header from disk into the in-memory log header
static void read_head(void)
{
struct buf *buf = bread(log.dev, log.start);
struct logheader *lh = (struct logheader *) (buf->data);
int i;
log.lh.n = lh->n;
for (i = 0; i < log.lh.n; i++) {
log.lh.block[i] = lh->block[i];
}
brelse(buf);
}
// Write in-memory log header to disk.
// This is the true point at which the
// current transaction commits.
static void write_head(void)
{
struct buf *buf = bread(log.dev, log.start);
struct logheader *hb = (struct logheader *) (buf->data);
int i;
hb->n = log.lh.n;
for (i = 0; i < log.lh.n; i++) {
hb->block[i] = log.lh.block[i];
}
bwrite(buf);
brelse(buf);
}
....
....: `write_log` 함수는 `인-메모리`에서 `로그 블락`으로 데이터를 쓰는 함수다. 이 함수가 동작하는 방식은 아래의 체크된 붉은 주석을 따라가면 된다. 먼저, 버퍼 캐시에서 필요한 데이터를 추출한다. 그리고 로그 블락 영역에 쓴다. 주의점은, 아직 `로그 블락`에서 `데이터 블락`으로는 가지 않은 상태다. 참고로, `로그 블락`을 버퍼 캐시 및 디스크에서 읽어올 때, `+1`을 하는 이유는 로그 블락 제일 첫 번째 섹터는 `로그 헤더`가 차지하고 있기 때문이다.
: `write_log` 함수는 `인-메모리` 개수를 기반으로 루프문을 돌린다. 아래 루프문을 보면 `tail < log.lh.n` 라는 조건이 있다. 그리고 그 앞에 아무 코드도 존재하지 않는다. 이 말은, `write_log` 함수가 전적으로 `log.lh.n` 값에 의존한다는 소리다. 그런데, `log.lh.n`값이 바뀌는 경우, 특히 굉장히 빈번하게 바뀌는 경우는 `log_write` 함수를 호출할 때 뿐이다. 다른 함수들에서도 `log.lh.n` 값이 변경되는 경우가 있지만, `log_write` 함수가 호출되는 것에 비하면 상당히 드물다.
// log.c
....
....
// Copy modified blocks from cache to log.
static void write_log(void)
{
int tail;
for (tail = 0; tail < log.lh.n; tail++) {
struct buf *to = bread(log.dev, log.start+tail+1); // log block
struct buf *from = bread(log.dev, log.lh.block[tail]); // cache block
memmove(to->data, from->data, BSIZE);
bwrite(to); // write the log
brelse(from);
brelse(to);
}
}: `install_trans` 함수는 `로그 블락`에서 `데이터 블락`으로 데이터를 쓴다. 아래에서 `thier home location` 이라는 하는 부분이 실제 데이터 영역을 의미한다. 그런데, 자세히 보면 `install_trans` 함수와 `write_log` 함수의 구조가 너무 비슷하다. 핵심적인 차이는 `bwrite` 함수다. `bwrite` 함수가 어디에 write 하는가가 핵심이다.
// log.c
....
....
// Copy committed blocks from log to their home location
static void install_trans(void)
{
int tail;
for (tail = 0; tail < log.lh.n; tail++) {
struct buf *lbuf = bread(log.dev, log.start+tail+1); // read log block
struct buf *dbuf = bread(log.dev, log.lh.block[tail]); // read dst
memmove(dbuf->data, lbuf->data, BSIZE); // copy block to dst
bwrite(dbuf); // write dst to disk
brelse(lbuf);
brelse(dbuf);
}
}: `commit` 함수는 `인-메모리` 데이터를 `로그 블락`으로 복사한 뒤, 다시 `로그 블락` 데이터를 실제 디스크의 `데이터 블락`으로 복사하는 함수다. 이 때, 재미있는 건 여기에 `로그 헤더`가 껴있다는 것이다. 근데, `로그 헤더`는 왜 저 위치에 있을까? `log.lh.n > 0` 조건은 반드시 필요할까?
// log.c
....
....
static void commit()
{
if (log.lh.n > 0) {
write_log(); // Write modified blocks from cache to log
write_head(); // Write header to disk -- the real commit
install_trans(); // Now install writes to home locations
log.lh.n = 0;
write_head(); // Erase the transaction from the log
}
}: 먼저, `log.lh.n > 0` 조건은 없다고 해서 에러가 발생하지는 않는다. 그러나, 변경된 데이터가 없는데 쓸데없이 함수를 호출해서 오버헤드를 막을 필요는 있어보인다.
: `write_head` 함수는 굳이, `write_log` 와 `install_trans` 함수 사이에 있어야 할까? `write_log`와 `write_head`의 위치는 상관없지만, `write_XXXX` 함수들과 `install_trans` 함수의 위치가 바껴서는 절대 안된다. `install_trans`는 `로그 영역`에서 `데이터 영역`으로 쓰는 함수이다. 이 말은 `install_trans` 함수가 `로그 영역`의 데이터가 최신이라는 것을 가정하고 `데이터 영역`에 쓴다는 것이다. 그러므로, `install_trans` 함수가 호출되기전에 미리 `로그 영역`이 최신화가 되어있어야 한다.
// log.c
....
....
// Caller has modified b->data and is done with the buffer.
// Record the block number and pin in the cache with B_DIRTY.
// commit()/write_log() will do the disk write.
//
// log_write() replaces bwrite(); a typical use is:
// bp = bread(...)
// modify bp->data[]
// log_write(bp)
// brelse(bp)
void log_write(struct buf *b)
{
int i;
if (log.lh.n >= LOGSIZE || log.lh.n >= log.size - 1)
panic("too big a transaction");
if (log.outstanding < 1)
panic("log_write outside of trans");
acquire(&log.lock);
for (i = 0; i < log.lh.n; i++) {
if (log.lh.block[i] == b->blockno) // log absorbtion
break;
}
log.lh.block[i] = b->blockno;
if (i == log.lh.n)
log.lh.n++;
b->flags |= B_DIRTY; // prevent eviction
release(&log.lock);
}: `log_write` 함수는 반드시 파일 시스템 관련 시스템 콜 트랜잭션안에서 호출되어야 한다. 즉, `begin_op`와 `end_op` 사이에서 호출되어야 한다. 먼저도 안되고, 뒤도 안된다. 반드시 저 둘 사이에 호출되어야 한다. 왜? `log_write` 함수의 역할은 `로그 헤더`의 데이터를 변경하는 것이다. 즉, 수정된 데이터 블락들의 개수를 카운팅하고, 수정된 데이터 블락들의 번호를 저장하고 있다. 만약에, `end_op` 이후에 `log_write`가 호출되면, 어떻게 될까? 결론부터 말하면, 데이터가 누락될 것이다.
: `로그 헤더`는 평상시에는 `인-메모리` 상태로 있다. `write_head` 함수가 호출되기 전까지는 계속 `인-메모리`에 존재한다. `write_head` 함수를 호출하면, `인-메모리`에 있던 `로그 헤더` 정보들이 디스크의 `로그 영역`에 써진다. 이 함수는 `commit` 함수에서 호출되고, `commit` 함수는 `end_op`에서 호출된다. 즉, `end_op` 이후에 `log_write` 함수를 호출한다는 말은, `write_head` 이후에 `log_write` 함수를 호출한다는 말과 같다. 이게 무슨 문제를 발생시킬까?
: `begin_op`가 호출됬다는 말은 파일 시스템 관련 트랜잭션이 발생했다는 뜻이고, `로그 영역`이 수정될 가능성이 있다는 말과 같다. 그리고 실제로 수정이 됬다고 치자. 그런데, `log_write`를 호출하지 않고, `end_op`를 호출했다고 치자. 그렇면, `commit`이 발생하고, 현재 `인-메모리`의 데이터들이 `로그 영역`에 써졌다가, 실제 `데이터 영역`으로 써지게 된다. 여기서 문제가 발생했다. `인-메모리`에서 `로그 영역`에 데이터를 복사할 때, `로그 헤더`의 카운트 값에 강하게 의존한다는 것이다. `로그 헤더` 카운트 값이 업데이트되지 않았으면(`log_write`가 호출되지 않으면), 아무리 `인-메모리`에 데이터를 써도(`write_head` & `write_log`) `로그 영역`에 써지지 않는다. 그래서 `log_write`는 반드시 `commit` 전에는 호출되어야 한다.
: 그런데, 아무런 문제가 발생하지 않을 수 도 있다. 그 이유는 이미 `로그 헤더`에 수정된 블락 번호가 써져 있다면, 문제가 발생하지 않을 것이다. `로그 헤더`에 기록되는 블락들은 새롭게 수정된 블락 번호들만 기록된다. `commit` 되기 전에 2번 수정된 블락이 있다면, 최초에 `로그 헤더`에 등록되고 두 번째 때는 이미 수정이 되어있다고 표시가 되어 있는 블락이므로, 개수를 증가시키지 않고 `log_write` 함수가 종료된다. 이렇게 그냥 넘어갈 수 있는 이유는 `log_write` 함수가 블락들의 데이터에 관심이 있는게 아니라, 수정된 블락 번호에 관심이 있기 때문이다.
: 그래서 `log_write` 함수안에 반드시 파일 시스템 트랜잭션안에 있어야 한다는 조건으로 `if (log.outstanding < 1)` 이 들어가 있다. 그 이후에 내용은 간단하다. 만약, `로그 헤더`에 등록되지 않은 블락이 들어온 경우, `로그 헤더`에 기록한다. 그리고, 해당 블락(버퍼 캐시)은 수정이 된 블락이라는 표시를 남기기 위해 `B_DIRTY` 표시를 한다. 이 표식을 남기면, 해당 블락을 다른 곳에서 사용하지 못하게 하면서도, 버퍼를 해제할 때 반드시 디스크에 동기화를 해야한다는 의미가 된다.
: 그리고, `log_write` 함수를 외부에 노출된 함수다. 이 함수에 주석에도 써있지만, `log_write` 함수를 호출한다는 것은 이 함수에 전달하는 버퍼 캐시(블락)이 수정된 블락이라는 것을 의미한다. 다른 말로 하면, 이 함수를 호출할 때, 수정되지 않은 버퍼 캐시를 전달할 경우, 문제가 발생할 수 도 있음을 의미한다. 왜냐면, 이 함수는 전달받은 버퍼 캐시가 무조건 수정된 버퍼 캐시라는 것을 가정하기 때문이다.
// log.c
...
...
// called at the start of each FS system call.
void begin_op(void)
{
acquire(&log.lock);
while(1){
if(log.committing){
sleep(&log, &log.lock);
} else if(log.lh.n + (log.outstanding+1)*MAXOPBLOCKS > LOGSIZE){
// this op might exhaust log space; wait for commit.
sleep(&log, &log.lock);
} else {
log.outstanding += 1;
release(&log.lock);
break;
}
}
}
// called at the end of each FS system call.
// commits if this was the last outstanding operation.
void end_op(void)
{
int do_commit = 0;
acquire(&log.lock);
log.outstanding -= 1;
if(log.committing)
panic("log.committing");
if(log.outstanding == 0){
do_commit = 1;
log.committing = 1;
} else {
// begin_op() may be waiting for log space,
// and decrementing log.outstanding has decreased
// the amount of reserved space.
wakeup(&log);
}
release(&log.lock);
if(do_commit){
// call commit w/o holding locks, since not allowed
// to sleep with locks.
commit();
acquire(&log.lock);
log.committing = 0;
wakeup(&log);
release(&log.lock);
}
}
...
...: `xv6` 파일 시스템에서 로그 레이어 관련 함수 중 가장 어려운 함수들은 `begin_op`와 `end_op`다.
: `log.outstanding`이 나타내는 값은 현재 `system calls in progress`의 개수를 의미한다. 즉, `미해결된 혹은 처리중인 시스템 콜의 개수`를 의미한다. `xv6` 로그 레이어는 파일 시스템 관련 시스템 콜이 정상적으로 마무리가 되기 위해서는 반드시 `begin_op`와`end_op`는 한 쌍을 이루어서 호출이 되어야 한다.
: `xv6`에서 `트랜잭션` 이라는 개념을 먼저 알아봐야 한다. `begin_op` 부터 알아보자. `begin_op`는 3가지 조건이 들어있다. 먼저 `if(log.committing)`은 파일 시스템 관련 시스템 콜을 통해 처리된 작업들이 메모리에서 디스크로 써지고 있다는 것을 의미한다. `log.committing`은 `end_op`에서 마지막 시스템 콜을 처리할 때, SET 된다. 여기서 마지막 `시스템 콜`이라는 뜻이 뭘까? 앞에서 말했지만, `xv6`는 `그룹 커밋`을 지원한다. 그래서, 시스템 콜이 누적되어 있을 경우, 마지막에 처리 될 시스템 콜을 제외하고는 일단 전부 `버퍼 캐시`에다가 처리하고, 마지막에 한 꺼 번에 디스크에 쓰기 위해 마지막 시스템 콜에서 `log.commiting`을 SET 하는 것이다. 근데, 시스템 콜이 쌓인다는 게 무슨 소리일까?
: 이 이야기를 하려면, 멀티 프로세서 환경이라 가정해야 한다. 어떤 파일 시스템 관련 시스템 콜 A 에서 `begin_op`가 호출됬다. 그런데, `end_op`가 호출되기 전에, 다른 파일 관련 시스템 콜 B 가 호출이 됬다. 그러면, 시스템 콜 A의 `end_op` 가 호출되기 전에 시스템 콜 B의 `begin_op`가 호출되게 된것이다. 그러면, 현재 전역적으로 봤을 때 `end_op`는 한 번도 호출되지 않았고 `begin_op`만 2번 호출되게 된 셈이다. 그렇면, `if(log.committing)` 이 조건에는 걸리지 않는다. 왜냐면, `end_op`가 호출된 적이 없기 때문이다.
: 두 번째 조건으로 `else if(log.lh.n + (log.outstanding+1)*MAXOPBLOCKS > LOGSIZE)` 가 있는데, 이 조건도 `log.lh.l`가 0 이라고 가정한다면, 걸리지 않는다. 이 조건문을 이해하기 위해서는 `xv6` 공식 문서를 참고해야 한다. 위에서 말했다시피, `log.outstanding`은 현재 처리가 진행중인 시스템 콜의 개수를 의미한다.
...
log.outstanding counts the number of system calls that have reserved log space; the total reserved space is log.outstanding times MAXOPBLOCKS. Incrementing log.outstanding both reserves space and prevents a commit from occuring during this system call. The code conservatively assumes that each system call might write up to `MAXOPBLOCKS` distinct blocks
...: `xv6`에서는 시스템 콜 하나 당 최대 로그 블락을 10개까지 사용할 수 있다고 가정한다. 그래서 `log.outstading`이 1이면, `end_op`가 호출되서 마무리 되기 전까지는 시스템 콜이 최대 10개까지 로그 블락을 사용할 것이라고 가정한다. 그래서 로그 블락을 계산할 때, `로그 블락 개수 = (log.outstanding * MAXOPBLOCKS)` 와 같은 식이 나오는 것이다. 그런데 왜 1을 더할까? 그 이유는 `log.outstading`에 1을 추가하는 코드가 맨 마지막에 검사되기 때문이다.
: `else if(log.lh.n + (log.outstanding+1)*MAXOPBLOCKS > LOGSIZE)` 조건문은 2번째로 검사되는 조건문이다. 그리고 마지막으로 앞에 조건들이 모두 들어맞지 않을 경우, `log.outstanding += 1`을 진행한다. 즉, `else if(log.lh.n + (log.outstanding+1)*MAXOPBLOCKS > LOGSIZE)` 조건을 검사하는 시점에는 이미 시스템 콜은 발생을 했지만, 아직 `log.outstading` 값에 1을 추가하지 못해서 이전 `log.outstanding` 값에 `1`을 더해서 조건을 검사하는 것이다. 그러면, 쉽게 `end_op`처럼 `log.outstading += 1`을 `begin_op`가 진입하는 시점에 작성하면 안될까? 내가 개인적으로 검토해본 결과에 의하면 가능하다. 아래와 같이 변경이 가능한 이유는 2가지다.
1" while 문안에 종료 조건이 else에서만 존재한다는 점 이다. 즉, while문이 종료되려면 반드시 else에 진입해야 한다.
2" else에 진입하면 `log.outstading`의 값에 1을 추가하고 곧 바로 while문은 종료된다. 즉, else문에는 다시 루프문을 반복하는 코드는 없다.: 위의 2가지 점을 착안해보면, `log.outstading += 1` 코드는 while 문 밖에 있어야 한다. 그래야 딱 한 번만 실행되기 때문이다. 대신 `acquire`와 `release` 안쪽에 배치해서 선점되지 않도록 주의한다.
// called at the start of each FS system call.
void
begin_op(void)
{
acquire(&log.lock);
while(1){
if(log.committing){
sleep(&log, &log.lock);
} else if(log.lh.n + (log.outstanding+1)*MAXOPBLOCKS > LOGSIZE) {
// this op might exhaust log space; wait for commit.
sleep(&log, &log.lock);
} else {
log.outstanding += 1;
release(&log.lock);
break;
}
}
}// called at the start of each FS system call.
void
begin_op(void)
{
acquire(&log.lock);
log.outstanding += 1;
while(1){
if(log.committing){
sleep(&log, &log.lock);
} else if(log.lh.n + (log.outstanding*MAXOPBLOCKS) > LOGSIZE) {
// this op might exhaust log space; wait for commit.
sleep(&log, &log.lock);
} else {
release(&log.lock);
break;
}
}
}: 그런데, `log.lh.n`은 왜 더할까? `log.lh.n`은 현재 `xv6` `사용 중인 로그 블락` 개수를 의미한다. 즉, 최종적으로 `log.lh.n + (log.outstanding+1)*MAXOPBLOCKS` 은 `현재 사용중인 로그 블락 개수 + 시스템 콜 때문에 사용될 것이라고 가정된 로그 블락 개수`를 의미한다. 이 값은 `xv6`에서 고정으로 할당한 로그 블락 개수 `LOGSIZE(30)`보다 크면 안된다. 만약, 더 크면 `end_op`가 호출되기를 기다리면서(sleep 함수) 로그 블락 개수의 여유가 생길 때 까지 대기한다.
: 이렇게 2번째 조건까지 넘아갔다고 치면, 마지막 `else` 조건을 만나면서 `log.outstanding += 1` 코드를 만나게 된다. 이렇게 현재 처리중인 시스템 콜이 2개가 된 셈이다. 이제 B의 `end_op`가 호출될 시점이 됬다. `end_op`에 진입하면, 일단 한 개의 시스템 콜 처리가 끝났다고 가정하고, `log.outstading` 값에서 1을 뺀다. 그런데, `xv6`는 `그룹 커밋` 방식을 이용하기 때문에, `log.outstading`의 값이 0 일 때만, 커밋을 한다.
: 이렇게 `xv6`의 `로그 레이어`를 마무리한다.
: 아쉬운 몇 가지를 언급하고 마무리 하려고 한다. `begin_op`와 `end_op` 함수가 파일 시스템 관련 시스템 콜 호출 시, 반드시 맨 앞과 뒤에 작성해야 한다면, 그걸 강제하기 위해 디자인 패턴을 적용하는게 어땟을 까 싶다. 예를 들어, `팩토리 메소드 패턴`과 같이 말이다.
'프로젝트 > 운영체제 만들기' 카테고리의 다른 글
[xv6] - fork (0) 2023.07.27 [xv6] - sbrk (0) 2023.07.26 [xv6] Buffer Cache (0) 2023.07.24 [xv6] inode (0) 2023.07.24 [xv6] Process (0) 2023.07.24