Bayangkan kita sedang menulis file konfigurasi penting—dan tiba-tiba listrik padam. Bila kita menulis langsung ke file target (fopen("config.json", "w")), file akan terpotong di tengah jalan: program yang lain membaca akan melihat partial content yang korup. Untuk mencegah skenario ini, kita pakai pola atomic file update: tulis ke file sementara, sync ke disk, lalu rename ke target. Operasi rename POSIX bersifat atomik—begitu selesai, file target berisi seluruh data baru; begitu gagal, file target tetap berisi data lama. Pada chapter ini kita akan mempraktikkan pola ini dan memahami kenapa fsync adalah bagian krusial yang sering dilupakan.

Masalah: Penulisan Tidak Atomik #

Cara naif menulis file adalah:

c
FILE *f = fopen("config.json", "w");   /* truncate file ke 0 byte */
fprintf(f, "{...}\n");
fclose(f);

Pendekatan ini punya tiga masalah:

  1. Window partial write — saat fopen mengembalikan pointer, file sudah terpotong menjadi 0 byte. Bila program lain membaca file pada saat ini, mereka akan melihat file kosong.
  2. Kehilangan data saat crash — bila program crash (atau listrik mati) sebelum fclose, file akan tetap kosong atau berisi partial content. Data lama sudah hilang.
  3. Race dengan reader — reader yang sedang membuka file bisa mendapat versi yang tidak konsisten.

Pola Atomik: Write-Temp-Sync-Rename #

Pola yang benar adalah:

  1. Tulis ke file temp dengan nama unik (misalnya target.tmp.XXXXXX).
  2. fsync file temp untuk memastikan data benar-benar di disk (bukan hanya di page cache kernel).
  3. rename(temp, target) — operasi atomik yang menggantikan file target dengan file temp.
  4. fsync direktori (opsional, untuk durability penuh) — agar rename tercatat di disk.
c
#define _XOPEN_SOURCE 700
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>

/* Tulis file secara atomik: tulis ke .tmp, fsync, rename ke target */
int atomic_write(const char *target, const char *data, size_t len) {
    /* 1. Bangun nama temp dengan suffix .tmp */
    char tmpname[4096];
    int n = snprintf(tmpname, sizeof(tmpname), "%s.tmp.XXXXXX", target);
    if (n < 0 || (size_t)n >= sizeof(tmpname)) {
        return -1;
    }

    /* 2. Buat file temp dengan mkstemp */
    int fd = mkstemp(tmpname);
    if (fd < 0) {
        perror("mkstemp");
        return -1;
    }

    /* 3. Tulis data dengan loop write(2) */
    size_t written = 0;
    while (written < len) {
        ssize_t w = write(fd, data + written, len - written);
        if (w < 0) {
            perror("write");
            close(fd);
            unlink(tmpname);
            return -1;
        }
        written += (size_t)w;
    }

    /* 4. fsync untuk memastikan data benar-benar di disk */
    if (fsync(fd) < 0) {
        perror("fsync");
        close(fd);
        unlink(tmpname);
        return -1;
    }
    close(fd);

    /* 5. Rename ke target - operasi atomik */
    if (rename(tmpname, target) < 0) {
        perror("rename");
        unlink(tmpname);
        return -1;
    }
    return 0;
}

int main(void) {
    /* Buat file awal */
    atomic_write("/tmp/test_atomic.txt", "versi awal\n", 11);
    printf("Versi awal ditulis\n");

    /* Baca dan tampilkan */
    FILE *f = fopen("/tmp/test_atomic.txt", "r");
    if (f) {
        char buf[64] = {0};
        fread(buf, 1, sizeof(buf)-1, f);
        printf("Isi sekarang: %s", buf);
        fclose(f);
    }

    /* Update ke versi baru */
    atomic_write("/tmp/test_atomic.txt", "versi baru setelah update\n", 27);
    printf("\nUpdate ke versi baru selesai\n");

    /* Baca kembali */
    f = fopen("/tmp/test_atomic.txt", "r");
    if (f) {
        char buf[64] = {0};
        fread(buf, 1, sizeof(buf)-1, f);
        printf("Isi sekarang: %s", buf);
        fclose(f);
    }

    /* Pastikan tidak ada file temp yang tersisa */
    if (access("/tmp/test_atomic.txt.tmp.XXXXXX", F_OK) != 0) {
        printf("\nTidak ada file temp tersisa (sudah direname)\n");
    }

    return 0;
}

Output yang Dihasilkan:

text
Versi awal ditulis
Isi sekarang: versi awal

Update ke versi baru selesai
Isi sekarang: versi baru setelah update

Tidak ada file temp tersisa (sudah direname)

Perhatikan output di atas. File test_atomic.txt awalnya berisi "versi awal\n". Setelah pemanggilan atomic_write kedua, file berisi "versi baru setelah update\n"—sepenuhnya diganti. Tidak ada momen di mana reader akan melihat file kosong atau partial content; rename POSIX bersifat atomik.

Mari kita telaah baris-baris kode di atas:

  1. Baris 9 (char tmpname[4096];): buffer untuk nama file temp. Kita pakai snprintf untuk membangun nama <target>.tmp.XXXXXX dengan aman.
  2. Baris 14 (int fd = mkstemp(tmpname);): membuat file temp dengan nama acak. mkstemp mengganti 6 karakter XXXXXX dengan karakter acak dan membuka file dengan O_CREAT | O_EXCL—mencegah race condition dengan penyerang yang mungkin membuat symlink dengan nama yang sama.
  3. Baris 21-30 (while (written < len)): loop write sampai semua byte ditulis. write(2) bisa mengembalikan kurang dari yang diminta—kita harus loop sampai written == len.
  4. Baris 33 (fsync(fd)): memaksa page cache kernel untuk mem-flush data file ke disk fisik. Tanpa fsync, data mungkin masih di RAM—bila listrik mati setelah rename tetapi sebelum kernel sempat flush, file target akan kosong.
  5. Baris 42 (rename(tmpname, target)): operasi atomik POSIX. Pada filesystem yang sama, rename menggantikan file target dengan file temp dalam satu langkah. Pembaca yang membuka file target sebelum rename akan melihat versi lama; setelah rename akan melihat versi baru. Tidak ada momen di mana file target kosong atau korup.
  6. Baris 56 (access("/tmp/test_atomic.txt.tmp.XXXXXX", F_OK)): memeriksa apakah masih ada file temp tersisa. Bila rename berhasil, file temp (dengan nama asli yang berisi karakter acak, bukan literal XXXXXX) sudah tidak ada.

Mengapa fsync Diperlukan #

Tanpa fsync, urutan operasi sebenarnya adalah:

  1. write(fd, data, len) — data masuk ke page cache kernel (RAM), belum ke disk.
  2. close(fd) — menutup file descriptor, tetapi data mungkin masih di page cache.
  3. rename(temp, target) — metadata file berganti (atomik), tetapi directory entry baru mungkin juga masih di page cache.
  4. Listrik mati. Saat sistem booting lagi, file target mungkin:
    • Tidak ada (rename belum sempat di-flush).
    • Ada tetapi kosong (rename sempat di-flush, tetapi data belum).
    • Ada dengan data lama (rename belum sempat di-flush).

Dengan fsync(fd) sebelum rename, kita memaksa kernel menulis isi file ke disk sebelum mengganti nama. Untuk durability penuh (rename juga harus tercatat di disk), kita perlu juga fsync direktori induk:

c
int dir_fd = open(dirname((char*)target), O_RDONLY);
if (dir_fd >= 0) {
    fsync(dir_fd);
    close(dir_fd);
}

Demonstrasi: Update File Konfigurasi #

Mari kita lihat skenario realistis: program membaca konfigurasi JSON, mengubah sebuah field, lalu menulis ulang secara atomik.

c
#define _XOPEN_SOURCE 700
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>

int atomic_write(const char *target, const char *data, size_t len) {
    char tmpname[4096];
    snprintf(tmpname, sizeof(tmpname), "%s.tmp.XXXXXX", target);

    int fd = mkstemp(tmpname);
    if (fd < 0) return -1;

    size_t written = 0;
    while (written < len) {
        ssize_t w = write(fd, data + written, len - written);
        if (w < 0) { close(fd); unlink(tmpname); return -1; }
        written += (size_t)w;
    }
    fsync(fd);
    close(fd);
    if (rename(tmpname, target) < 0) {
        unlink(tmpname);
        return -1;
    }
    return 0;
}

int main(void) {
    /* Tulis konfigurasi awal */
    const char *config_v1 = "{\"version\": 1, \"port\": 8080}\n";
    atomic_write("/tmp/test_config.json", config_v1, strlen(config_v1));
    printf("Config v1 ditulis\n");

    /* Update: ganti port ke 9090, tambahkan host */
    const char *config_v2 = "{\"version\": 2, \"port\": 9090, \"host\": \"0.0.0.0\"}\n";
    atomic_write("/tmp/test_config.json", config_v2, strlen(config_v2));
    printf("Config v2 ditulis (update atomik)\n");

    /* Baca untuk verifikasi */
    FILE *f = fopen("/tmp/test_config.json", "r");
    if (f) {
        char buf[256] = {0};
        fread(buf, 1, sizeof(buf)-1, f);
        printf("Isi final: %s", buf);
        fclose(f);
    }

    /* Daftar isi /tmp untuk konfirmasi tidak ada file temp tersisa */
    printf("\n(gunakan 'ls /tmp/test_config*' untuk verifikasi)\n");

    return 0;
}

Output yang Dihasilkan:

text
Config v1 ditulis
Config v2 ditulis (update atomik)
Isi final: {"version": 2, "port": 9090, "host": "0.0.0.0"}
(gunakan 'ls /tmp/test_config*' untuk verifikasi)

Output di atas menunjukkan bahwa setelah dua kali pemanggilan atomic_write, file test_config.json berisi konfigurasi terbaru (version: 2). Tidak ada partial write, tidak ada file temp tersisa. Program lain yang membaca test_config.json pada saat atomic_write berjalan akan melihat salah satu dari dua versi (lama atau baru), tidak pernah file kosong atau korup.

Edge Cases dan Pertimbangan #

Beberapa hal yang perlu diperhatikan saat menerapkan pola ini:

  1. Filesystem yang sama — rename hanya atomik bila temp dan target berada di filesystem yang sama. Bila berbeda, rename bisa gagal atau melakukan copy + delete yang tidak atomik. Selalu buat file temp di direktori yang sama dengan target.
  2. Izin file — mkstemp membuat file dengan mode 0600 (hanya pemilik). Bila target harus punya izin berbeda (misalnya 0644), gunakan fchmod(fd, 0644) sebelum close.
  3. Owner file — mkstemp membuat file dengan owner adalah proses pemanggil. Bila target harus punya owner berbeda (misalnya file sistem), rename akan gagal kecuali proses berjalan sebagai root.
  4. Quota — bila disk penuh, write akan gagal. Pastikan kita membersihkan file temp di jalur error.
  5. Network filesystem — pada NFS, rename tidak selalu atomik. Untuk NFS, gunakan file locking atau protokol tingkat aplikasi yang lebih kuat.