C/C++ file descriptor: không chỉ là đọc ghi file
Trong bài viết này, mình sẽ giải thích về file descriptor trong C/C++, nguyên lý hoạt động và các tương tác thú vị với nó.
Về định nghĩa: file descriptor là một số nguyên không âm được sử dụng trong một tiến trình đang thực thi để xác định một file đang được mở.
Bất kỳ một chương trình C/C++ nào với các chức năng tương tác với người dùng qua terminal cũng sử dụng ít nhất 3 file descriptor: stdin, stdout và stderr. Với những người mới bắt đầu sử dụng C/C++, các thao tác input/output(io) được trừu tượng hóa qua các thư viện và hàm xây dựng trên file descriptor như C với printf
, scanf
, perror
hay C++ std với cin
, cout
, cerr
và thường sẽ không cần phải quan tâm đến file descriptor.
Đi sâu hơn một chút, khi bắt đầu học cách xử lý file trong C, ta có thể bắt gặp các hàm như open
, read
, write
, close
để thao tác với file, ta nhận thấy các hàm này thao tác với một biến kiểu int
mà các tài liệu gọi là “file descriptor”. Mặc dù không cần tìm hiểu, ta cũng có thể hình dung ra định nghĩa: file descriptor là một con số mà chương trình sử dụng để xác định file mà nó đang thao tác.
Tuy nhiên, file descriptor không chỉ đơn giản là vậy. Trong quá trình phát triển kỹ năng lập trình C, ta sẽ nhận thấy sự xuất hiện của file descriptor trong các ngữ cảnh không liên quan đến file và các thao tác với file. Trong bài viết này, mình sẽ tìm hiểu cách hoạt động của file descriptor, các cách sử dụng file descriptor và các chức năng của file descriptor.
Everything is a file
Hệ thống file của Unix vận hành trên ý tưởng “Everything is a file” hay “Mọi thứ đều là file”. Nghĩa là, hầu như tất cả mọi thứ được quản lý dưới dạng một file: từ các file thực sự trên ổ cứng, các thiết bị như ổ đĩa, bàn phím, chuột, màn hình, đến các kết nối mạng, socket, pipe, shared memory,… Ý tưởng này giải thích sự xuất hiện của file descriptor trong các ngữ cảnh không liên quan đến file như lập trình socket hay pipe: trong Unix, mọi thứ đều được xem như một file, và một file có thể được xác định trong chương trình bằng file descriptor.
Cách file descriptor hoạt động
Vì file descriptor là một số nguyên không âm, ta có thể biên dịch và chạy thử chương trình C dưới đây:
Hãy tạo 3 file
file1
,file2
,file3
và đặt các file này vào cùng directory với chương trình trước khi thực hiện.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
int main(int argc, char const *argv[]) {
// Mở file 1
int fd1 = open("file1", O_RDONLY);
if (fd1 == -1) {
perror("Error opening file1\n");
return 1;
}
// Mở file 2
int fd2 = open("file2", O_RDONLY);
if (fd2 == -1) {
perror("Error opening file2\n");
return 1;
}
printf("Fd for file1: %d\n", fd1);
printf("Fd for file2: %d\n", fd2);
// Đóng file 1
printf("Closing file1\n");
close(fd1);
// Mở file 3
int fd3 = open("file3", O_RDONLY);
if (fd3 == -1) {
perror("Error opening file3\n");
return 1;
}
printf("Fd for file3: %d\n", fd3);
close(fd2);
close(fd3);
return 0;
}
Sau khi chạy chương trình, ta sẽ thấy kết quả như sau:
1
2
3
4
Fd for file1: 3
Fd for file2: 4
Closing file1
Fd for file3: 3
Từ output của ví dụ trên, ta có thể đưa ra 2 nhận xét:
- Các file descriptor được mở liên tiếp có giá trị tăng dần.
- Sau khi đóng file1 và mở file3, file descriptor cho file3 có giá trị bằng với file descriptor đã được đóng.
File descriptor table
Từ 2 nhận xét trên, ta nhận thấy file descriptor không phải được tạo ra một cách ngẫu nhiên mà được tạo ra theo một quy luật nào đó. Lật lại phần mở đầu, mình đã giới thiệu rằng mỗi chương trình đều mặc định sở hữu 3 file descriptor: stdin, stdout và stderr. Và từ output ví dụ, các file descriptor được tạo ra có giá trị bắt đầu từ 3. Nhớ rằng, (hầu hết) tất cả các bộ đếm trong lập trình mặc định bắt đầu từ 0, và 3 vị trí đầu (0, 1, 2) có vẻ đã được dành cho stdin, stdout và stderr. Vậy nghĩa là hẳn phải có một cấu trúc dữ liệu nào đó lưu trữ thông tin về file descriptor và tạo ra file descriptor mới dựa trên các file descriptor đã được tạo ra trước đó.
Cấu trúc dữ liệu này được gọi là File descriptor table. Một cách đơn giản, đây là một mảng các số nguyên không âm liên tiếp, mỗi phần tử trong mảng là một file descriptor. Khi ta tạo ra một file descriptor mới, hệ thống sẽ tìm vị trí đầu tiên trong mảng chưa được sử dụng và gán giá trị cho file descriptor mới. Khi ta đóng một file descriptor, vị trí tương ứng trong mảng sẽ được đánh dấu là chưa được sử dụng và có thể được sử dụng cho file descriptor được tạo ra sau này.
Độ rộng của file descriptor table là cố định và được xác định bởi hệ thống. Trong hầu hết các hệ điều hành hiện đại, độ rộng này thường là 1024 hoặc 4096 và có thể được kiểm tra bằng cách chạy câu lệnh
ulimit -n
trên terminal.
Open file table và Inode table
Việc mở một file trong nhiều chương trình là một tính năng cần thiết. Tuy nhiên, để cho phép tính năng này hoạt động, hệ điều hành cần phải quản lý các file được mở này nhằm kiểm soát việc truy cập và thay đổi dữ liệu của file. Trong Unix, mỗi file được mở sẽ được quản lý thông qua một cấu trúc dữ liệu gọi là Open file table. Open file table được chia sẻ giữa tất cả các tiến trình và mỗi lần một file được mở, một entry mới sẽ được tạo ra trong Open file table. Vậy có nghĩ là, mỗi lần ta muốn tạo một file descriptor, hệ thống sẽ tạo một entry mới trong Open file table và gán file descriptor cho entry này.
Tuy nhiên, các entry trong Open file table không chứa vị trí của file mà chỉ chứa metadata của file trong lần mở đó, ví dụ như vị trí con trỏ đọc/ghi, quyền truy cập, trạng thái file,… Dữ liệu thực sự về thông tin file được lưu trữ trong một cấu trúc dữ liệu khác gọi là Inode table. Mỗi file trên hệ thống Unix sẽ có một entry trong Inode table, và mỗi entry này sẽ chứa thông tin về file như kích thước, quyền truy cập, vị trí lưu trữ trên ổ cứng,…
Bạn có thể theo dõi các entry của inode table trong folder hiện tại bằng cách chạy câu lệnh
ls -i
trên terminal. Con số đứng trước tên file chính là index của file trong inode table.
TL;DR
Cách file descriptor map đến file (Nguồn: wikimedia)
- File descriptor là một số nguyên không âm được sử dụng để xác định file đang được mở.
- File descriptor không trực tiếp map đến file mà tạo ra và map đến một entry trong Open file table.
- Một entry trong Open file table chứa metadata liên quan đến việc đọc ghi file.
- Một entry trong Open file table map đến một entry trong Inode table.
- Có thể có nhiều entry của Open file table map đến một entry của Inode table.
- Một entry của Inode table chứa thông tin thực sự về file như kích thước, quyền truy cập, vị trí lưu trữ trên ổ cứng,…
Tương tác với file descriptor
Trong mục này, mình sẽ giới thiệu về những hàm mà mình thấy thường được xử dụng khi làm việc với file descriptor. Đồng thời cũng sẽ nói thêm về các tương tác với file descriptor trong một số trường hợp.
Tạo file descriptor
File descriptor là một số nguyên không âm, vì vậy, nếu một file descriptor không thể được tạo ra, hàm tạo file descriptor sẽ trả về -1. Ta nên tạo thói quen kiểm tra giá trị trả về của hàm tạo file descriptor trước khi sử dụng file descriptor đó để tránh các lỗi không mong muốn.
open
Hàm open
được định nghĩa trong thư viện <fcntl.h>
được sử dụng để mở một file và trả về một file descriptor của file vừa được mở:
1
int open(const char *pathname, int flags);
với flags là một số nguyên biểu diễn cách mở file. Các macro được định nghĩa cho flags:
O_RDONLY
: Mở file chỉ để đọc.O_WRONLY
: Mở file chỉ để ghi.O_RDWR
: Mở file để đọc và ghi.O_CREAT
: Tạo file nếu file không tồn tại.O_TRUNC
: Xóa nội dung file nếu file đã tồn tại.O_APPEND
: Ghi vào cuối file.
creat
Cũng được định nghĩa trong <fcntl.h>
, creat
hoạt động tương tự như open
với flags là O_WRONLY | O_CREAT | O_TRUNC
.
socket và accept
Trong lập trình socket, ta cũng sử dụng file descriptor để xác định socket và kết nối. Hàm socket
và accept
được định nghĩa trong thư viện <sys/socket.h>
:
1
2
int socket(int domain, int type, int protocol);
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
Các tham số của 2 hàm này khá phức tạp và nằm ngoài phạm vi bài viết này nên mình sẽ tạm thời bỏ qua. Một cách tổng quát, 2 hàm này được sử dụng để tạo ra một socket và chấp nhận kết nối từ một socket khác, và như đã giải thích, trong Unix, socket cũng được xem như một file nên sẽ có một file descriptor tương ứng được tạo ra.
pipe
Hàm pipe
được định nghĩa trong <unistd.h>
và được sử dụng để tạo ra một pipe giữa 2 tiến trình:
1
int pipe(int pipefd[2]);
với pipefd
là một mảng 2 phần tử chứa file descriptor của 2 đầu của pipe. Một đầu của pipe sẽ được sử dụng để ghi và một đầu sẽ được sử dụng để đọc.
Đóng file descriptor
Như đã đề cập, số lượng entry của File descriptor table là cố định và hệ thống sẽ không tạo ra thêm file descriptor nếu đã đạt đến giới hạn. Vì vậy, ta nên tạo thói quen đóng file descriptor sau khi không cần sử dụng nữa. Để đóng file descriptor, ta sử dụng hàm close
trong thư viện <unistd.h>
:
1
int close(int fd);
với fd
là file descriptor cần đóng. Hàm close đồng thời trả về một số nguyên biểu diễn kết quả của việc đóng file descriptor. Nếu đóng thành công, hàm trả về 0, ngược lại trả về -1.
Tương tác với file descriptor
read và write
Hàm read
và write
được định nghĩa trong thư viện <unistd.h>
và được sử dụng để đọc và ghi dữ liệu vào file descriptor:
1
2
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
với fd
là file descriptor, buf
là con trỏ trỏ đến vùng nhớ chứa dữ liệu cần đọc/ghi và count
là số byte cần đọc/ghi. Hàm read
và write
trả về số byte đã đọc/ghi hoặc -1 nếu gặp lỗi.
Về cơ bản, hàm read
và write
hoạt động tương tự như fread
và fwrite
trong thư viện stdio.h
nhưng không có buffer nên việc đọc/ghi dữ liệu sẽ được thực hiện trực tiếp vào file descriptor.
recv và send
Trong lập trình socket, ta cũng sử dụng hàm recv
và send
để đọc và ghi dữ liệu vào các socket fd:
1
2
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
với sockfd
là socket file descriptor, buf
là con trỏ trỏ đến vùng nhớ chứa dữ liệu cần đọc/ghi, len
là số byte cần đọc/ghi và flags
là các cờ điều khiển. Hàm recv
và send
cũng trả về số byte đã đọc/ghi hoặc -1 nếu gặp lỗi.
Khác với read
và write
, recv
và send
còn có thêm tham số flags
để điều khiển cách thức đọc/ghi dữ liệu từ socket.
lseek và llseek
Hàm lseek
và llseek
được sử dụng để di chuyển con trỏ đọc/ghi trong file descriptor:
1
2
off_t lseek(int fd, off_t offset, int whence);
off64_t llseek(int fd, off64_t offset, unsigned int whence);
fstat
Hàm fstat
được sử dụng để lấy thông tin về file descriptor:
1
int fstat(int fd, struct stat *buf);
với fd
là file descriptor và buf
là con trỏ trỏ đến struct stat
chứa thông tin về file descriptor.
fstat
lấy thông tin từ Inode table thông qua file descriptor và lưu vào struct stat
. Struct stat
chứa thông tin như kích thước file, quyền truy cập,…
Các tương tác socket
Trong lập trình socket, ta còn sử dụng các hàm như bind
, listen
, connect
, accept
, shutdown
,… để tương tác với socket. Các hàm này cũng sử dụng file descriptor để xác định socket và kết nối.
mmap và munmap
Hàm mmap
và munmap
được sử dụng để ánh xạ một file vào bộ nhớ:
1
2
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);
với addr
là địa chỉ bắt đầu của vùng nhớ được ánh xạ, length
là kích thước vùng nhớ cần ánh xạ, prot
là quyền truy cập, flags
là cờ điều khiển, fd
là file descriptor của file cần ánh xạ và offset
là vị trí bắt đầu ánh xạ.
Việc ánh xạ file vào bộ nhớ giúp ta truy cập dữ liệu trong file một cách hiệu quả hơn và cũng giúp giảm thiểu việc đọc/ghi dữ liệu từ file descriptor.
Các flags của hàm mmap
:
MAP_SHARED
: Ánh xạ file vào bộ nhớ và cho phép các tiến trình khác truy cập.MAP_PRIVATE
: Ánh xạ file vào bộ nhớ nhưng không cho phép các tiến trình khác truy cập.MAP_ANONYMOUS
: Ánh xạ một vùng nhớ không liên kết với file.
Trên một số hệ điều hành, giá trị của
offset
phải là bội số của kích thước trang vật lý của hệ thống. Để lấy kích thước trang vật lý, ta có thể sử dụng hàmsysconf(_SC_PAGESIZE)
.
Quản lý nhiều file descriptor một lúc
Trong một số trường hợp, ta cần quản lý nhiều file descriptor một lúc, ví dụ như khi một server đợi nhiều kết nối từ nhiều client khác nhau. Trong trường hợp này, ta cần sử dụng hàm select
hoặc poll
để quản lý nhiều file descriptor một cách hiệu quả.
select
Hàm select
được sử dụng để chờ đợi sự kiện xảy ra trên một hoặc nhiều file descriptor:
1
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
với nfds
là số lớn nhất của file descriptor, readfds
, writefds
, exceptfds
là các tập hợp file descriptor cần kiểm tra, timeout
là thời gian chờ đợi.
Hàm select
sẽ chờ đợi sự kiện xảy ra trên các file descriptor trong readfds
, writefds
, exceptfds
trong khoảng thời gian timeout
. Nếu có sự kiện xảy ra, hàm trả về số file descriptor có sự kiện xảy ra, ngược lại trả về 0.
poll
Hàm poll
cũng được sử dụng để chờ đợi sự kiện xảy ra trên một hoặc nhiều file descriptor:
1
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
với fds
là một mảng các struct pollfd
chứa thông tin về file descriptor cần kiểm tra, nfds
là số lớn nhất của file descriptor, timeout
là thời gian chờ đợi.
Hàm poll
hoạt động tương tự như select
nhưng cung cấp cách tiếp cận linh hoạt hơn và hiệu quả hơn.
Kết luận
Thông qua bài viết này, mình đã giới thiệu về file descriptor, cách file descriptor hoạt động và các hàm thao tác với file descriptor. File descriptor không chỉ đơn giản là một số nguyên dùng để xác định file mà còn là một cách tiếp cận để tương tác linh hoạt với nhiều loại file khác nhau như socket, pipe, shared memory,…
Việc hiểu rõ về file descriptor sẽ giúp ta hiểu rõ hơn về cách hoạt động của hệ thống Unix và giúp ta tận dụng tối đa các tính năng của hệ thống, đồng thời hiểu rõ hơn nếu gặp phải các lỗi liên quan đến file descriptor.
That’s it! Hy vọng mọi người tìm thấy những thông tin hữu ích từ bài viết này. Cảm ơn mọi người đã đọc đến đây và hẹn gặp lại ở những bài viết sau 🥺