Bỏ qua điều hướng, tới nội dung chính
Học C
Bài 18.1026 phút đọc

Ghi an toàn và xử lý lỗi

Sau bài này bạn sẽ làm được

  • Cài mẫu ghi ra tệp tạm rồi đổi tên và giải thích vì sao nó an toàn
  • Phân biệt fflush với fsync và biết cái nào chạm tới đĩa
  • Xử lý đủ các lỗi đĩa đầy, không có quyền và tệp đang bị khóa
  • Viết hàm lưu trả về mã lỗi rõ ràng thay vì im lặng

Người dùng bấm Lưu, đèn ổ cứng nhấp nháy, rồi mất điện. Chương trình viết cẩn thận thì họ mất công việc của năm phút vừa rồi. Chương trình viết ẩu thì họ mất tất cả. Khác biệt nằm ở ba dòng mã.

#Cửa sổ nguy hiểm

FILE *f = fopen("du-lieu.dat", "wb");    /* tep cu bi xoa NGAY tai day */

for (size_t i = 0; i < n; ++i)
    ghi_ban_ghi(f, &ds[i]);              /* mat vai giay voi du lieu lon */

fclose(f);
terminal
./thu-cua-so
truoc khi ghi de truc tiep:
  dl2.txt: [du lieu cu rat quan trong] (25 byte)
ngay sau fopen che do "wb", chua ghi byte nao:
  dl2.txt: 0 byte

#Ghi ra tệp tạm rồi đổi tên

Ở cách an toàn, tệp đích chỉ thay đổi tại đúng một thời điểm, và thao tác đó không chia nhỏ được.
Thao tác nguyên tử
Thao tác mà người quan sát chỉ thấy được hai trạng thái: chưa làm, và đã làm xong. Không có trạng thái ở giữa. Lệnh rename trong cùng một hệ tệp là thao tác nguyên tử trên POSIX.
luu-an-toan.c
#include <errno.h>
#include <stdio.h>
#include <string.h>

int luu_an_toan(const char *ten, const SinhVien *ds, size_t n) {
    char tam[512];

    int k = snprintf(tam, sizeof tam, "%s.tmp", ten);
    if (k < 0 || (size_t)k >= sizeof tam) return -1;      /* ten qua dai */

    FILE *f = fopen(tam, "wb");
    if (!f) { perror(tam); return -1; }

    /* Tu day tro di, moi that bai deu phai XOA TEP TAM */
    for (size_t i = 0; i < n; ++i)
        if (ghi_ban_ghi(f, &ds[i]) != 0) goto hong;

    if (fflush(f) != 0) goto hong;

    if (fclose(f) != 0) {                 /* fclose that bai: dung goto vi
                                             f da khong con hop le */
        remove(tam);

        return -1;
    }

    /* Tren Windows, rename khong ghi de duoc tep da ton tai */
    remove(ten);

    if (rename(tam, ten) != 0) {
        perror("rename");
        remove(tam);

        return -1;
    }

    return 0;

hong:
    fclose(f);
    remove(tam);

    return -1;
}
terminal
./thu-luu-an-toan
sau lan luu dau:
  dl.txt: [phien ban 1] (11 byte)
  dl.txt.tmp: KHONG TON TAI
sau lan luu hai:
  dl.txt: [phien ban 2, dai hon nhieu] (26 byte)
  dl.txt.tmp: KHONG TON TAI
  1. Ghi toàn bộ ra tệp tạm

    Tệp đích chưa hề bị đụng tới. Nếu bước này thất bại vì bất cứ lý do gì, bạn xóa tệp tạm và tệp cũ vẫn nguyên vẹn.

  2. Xả bộ đệm và đóng

    Sau fclose thành công, mọi byte đã ở trong tay hệ điều hành. Đây cũng là nơi lỗi đĩa đầy lộ ra, xem lại Bài 18.2.

  3. Đổi tên

    Một lời gọi duy nhất. Trước nó, tệp đích là bản cũ. Sau nó, là bản mới. Không có khoảnh khắc nào nó là bản dở dang.

#fflush chưa phải là đã nằm trên đĩa

Sau lời gọiSống sót khi chương trình sậpSống sót khi mất điệnChi phí
Chỉ fwriteKhôngKhôngGần bằng không
fflushCóKhôngMột lời gọi hệ thống
fflush rồi fsyncCóCóChờ đĩa, có thể vài mili giây
fsync cả thư mụcCóCó, kể cả tên tệp mớiThêm một lần chờ đĩa nữa
Bản đầy đủ trên POSIX
#include <unistd.h>       /* fsync   */
#include <fcntl.h>        /* open    */

int luu_that_an_toan(const char *ten, const SinhVien *ds, size_t n) {
    char tam[512];
    snprintf(tam, sizeof tam, "%s.tmp", ten);

    FILE *f = fopen(tam, "wb");
    if (!f) return -1;

    for (size_t i = 0; i < n; ++i)
        if (ghi_ban_ghi(f, &ds[i]) != 0) { fclose(f); remove(tam); return -1; }

    /* 1. Bo dem thu vien -> nhan he dieu hanh */
    if (fflush(f) != 0)          { fclose(f); remove(tam); return -1; }

    /* 2. Bo dem nhan -> mat dia */
    if (fsync(fileno(f)) != 0)   { fclose(f); remove(tam); return -1; }

    if (fclose(f) != 0)          { remove(tam); return -1; }

    /* 3. Doi ten */
    if (rename(tam, ten) != 0)   { remove(tam); return -1; }

    /* 4. Ban ghi ve TEN TEP nam trong thu muc, cung can dong bo */
    int td = open(thu_muc_cua(ten), O_RDONLY);

    if (td >= 0) { fsync(td); close(td); }

    return 0;
}

#Mã lỗi thay cho thông báo

Một mã lỗi duy nhất cho mọi chuyện
int luu(const char *ten) {
    FILE *f = fopen(ten, "wb");

    if (!f) {
        printf("Loi!\n");        /* nguoi dung biet gi? khong biet gi ca */

        return -1;
    }

    /* ... */
}

/* Nguoi goi nhan -1 va cung khong biet gi hon:
     tep khong mo duoc?
     dia day?
     ten khong hop le?
   Ho khong the hien thi thong bao huu ich, khong the tu quyet dinh
   nen thu lai hay bo cuoc. */
Kiểu enum có mô tả
/* trong quanly.h */
typedef enum {
    QL_OK = 0,
    QL_LOI_MO_TEP,
    QL_LOI_GHI,
    QL_LOI_DOC,
    QL_LOI_BO_NHO,
    QL_TEP_HONG,
    QL_SAI_PHIEN_BAN,
    QL_TEN_QUA_DAI,
} MaLoi;

const char *ql_mo_ta_loi(MaLoi m);

/* trong quanly.c */
const char *ql_mo_ta_loi(MaLoi m) {
    switch (m) {
        case QL_OK:             return "thanh cong";
        case QL_LOI_MO_TEP:     return "khong mo duoc tep";
        case QL_LOI_GHI:        return "loi khi ghi, dia co the day";
        case QL_LOI_DOC:        return "loi khi doc";
        case QL_LOI_BO_NHO:     return "khong du bo nho";
        case QL_TEP_HONG:       return "tep hong hoac sai dinh dang";
        case QL_SAI_PHIEN_BAN:  return "tep do phien ban moi hon tao ra";
        case QL_TEN_QUA_DAI:    return "duong dan qua dai";
    }

    return "loi khong ro";
}
Dùng ở tầng giao diện
MaLoi m = ql_luu(&ql, ten);

if (m != QL_OK) {
    fprintf(stderr, "Khong luu duoc %s: %s\n", ten, ql_mo_ta_loi(m));

    if (m == QL_LOI_GHI)
        fprintf(stderr, "Hay giai phong bot dung luong roi thu lai.\n");

    return 1;
}

printf("Da luu %zu sinh vien vao %s\n", ql.n, ten);

#Danh sách lỗi phải xử lý

LỗiXảy ra khiXử lý thế nào
fopen trả NULLKhông có tệp, không có quyền, hết số hiệu tệpBáo tên tệp cùng strerror(errno), dừng thao tác
fwrite trả về ít hơn nĐĩa đầy, hạn ngạch, ổ bị rútXóa tệp tạm, giữ nguyên tệp cũ, báo lỗi ghi
fclose trả khác 0Xả bộ đệm thất bạiCoi như ghi thất bại, xóa tệp tạm
malloc trả NULLHết bộ nhớ, hoặc số bản ghi trong tệp bị hỏngGiải phóng những gì đã cấp, trả QL_LOI_BO_NHO
Số nhận dạng không khớpNgười dùng chọn nhầm tệpTừ chối ngay, không đọc thêm byte nào
Mã kiểm tra không khớpTệp hỏng, chép dở, sửa tayTừ chối, gợi ý dùng bản .bak
Tệp cụt giữa bản ghiLần ghi trước bị ngắtBáo đọc được bao nhiêu trong bao nhiêu, hỏi có nạp phần đó không
rename thất bạiTệp đang mở, khác phân vùng, không có quyềnXóa tệp tạm, báo lỗi, tệp cũ vẫn còn

Tự làm thử

  1. Viết chương trình mở một tệp có sẵn bằng "wb" rồi dừng lại, và kiểm tra kích thước tệp từ một cửa sổ khác.
  2. Cài luu_an_toan đầy đủ và xác nhận không có tệp .tmp nào còn lại sau khi chạy.
  3. Cố ý làm ghi_ban_ghi thất bại ở bản ghi thứ mười, rồi kiểm tra tệp cũ còn nguyên vẹn.
  4. Thêm bản sao lưu .bak và kiểm tra nó chứa đúng nội dung của lần lưu trước.
  5. Cắt cụt một tệp dữ liệu bằng truncate và xác nhận chương trình báo lỗi rõ ràng thay vì nạp dữ liệu rác.

Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.

Tóm tắt

  • Mở tệp đích bằng "w" xóa nó ngay lập tức, tạo ra một cửa sổ nguy hiểm kéo dài suốt quá trình ghi.
  • Ghi ra tệp tạm cùng thư mục rồi rename lên tệp đích, nhờ đó luôn tồn tại một bản đầy đủ.
  • fflush chống được chương trình sập, còn fsync mới chống được mất điện. Gọi fsync một lần ở cuối, không gọi trong vòng lặp.
  • Trả về mã lỗi kiểu enum có hàm mô tả, để tầng giao diện quyết định cách nói với người dùng.
  • Mọi con số đọc từ tệp đều là dữ liệu không đáng tin. Kiểm tra trước khi nhân nó với kích thước bản ghi.