TECH NOTE · AUTOMATION OPS
파일 잠금과 확장 속성은 크레이트 없이 시스템 콜을 직접 선언해 쓸 수 있습니다. 상수를 직접 적어야 하고 getxattr는 호출이 두 번이라는 점만 알면 됩니다.
파일 잠금 하나 때문에 크레이트를 받아야 하나
예제 코드를 만들면서 파일 잠금이 필요했다. 두 프로세스가 같은 파일을 동시에 쓰면 내용이 섞이는 상황을 보여줘야 했다.
fs2나 nix 같은 크레이트를 쓰면 한 줄이다. 그런데 예제에 의존성이 붙으면 받는 사람이 cargo build를 할 때 크레이트를 내려받아야 하고, 버전이 안 맞으면 거기서 막힌다.
잠금 함수 하나 쓰려고 그걸 감수할 이유가 없다. 직접 선언해서 쓰면 된다.
`flock` 선언하기
C 함수를 Rust에서 부르려면 시그니처를 적어 주면 된다.
use std::os::fd::AsRawFd;
const LOCK_EX: std::os::raw::c_int = 2;
const LOCK_NB: std::os::raw::c_int = 4;
const LOCK_UN: std::os::raw::c_int = 8;
unsafe extern "C" {
fn flock(fd: std::os::raw::c_int, operation: std::os::raw::c_int) -> std::os::raw::c_int;
}
libc를 링크하는 건 표준 라이브러리가 이미 하고 있어서 따로 설정할 게 없다. 선언만 하면 부를 수 있다.
쓰는 쪽은 이렇다.
pub fn lock_exclusive(file: &File) -> Result<(), StoreError> {
if unsafe { flock(file.as_raw_fd(), LOCK_EX) } == 0 {
Ok(())
} else {
Err(StoreError::StorageUnavailable)
}
}
pub fn try_lock_exclusive(file: &File) -> bool {
unsafe { flock(file.as_raw_fd(), LOCK_EX | LOCK_NB) == 0 }
}
두 함수의 차이는 LOCK_NB 하나다. 붙이면 기다리지 않고 바로 돌아온다.
상수는 직접 확인해야 한다
여기가 이 방식의 실제 비용이다. LOCK_EX = 2 같은 값은 헤더 파일에 있고, 크레이트를 쓰면 남이 확인해 준 값을 쓰는 것이다.
직접 적으면 내가 확인해야 한다. 헤더 위치가 플랫폼마다 다른 것부터 걸린다.
Linux는 /usr/include에 있지만 macOS에는 그 경로가 없다. Xcode SDK 안으로 들어갔다.
grep -n "define.*LOCK_" "$(xcrun --show-sdk-path)/usr/include/sys/fcntl.h"
486:#define LOCK_SH 0x01 /* shared file lock */
487:#define LOCK_EX 0x02 /* exclusive file lock */
488:#define LOCK_NB 0x04 /* don't block when locking */
489:#define LOCK_UN 0x08 /* unlock file */
16진수로 적혀 있어서 그대로 옮기면 안 된다. 0x02가 2다.
이 값들은 macOS와 Linux가 같지만 전부 그런 건 아니다. O_NONBLOCK처럼 플랫폼마다 갈리는 것도 있다.
틀리면 컴파일은 되고 동작만 이상해진다. 타입이 c_int라 어떤 숫자든 통과하기 때문이다. 그래서 테스트로 확인하는 게 필수다.
#[test]
fn a_second_holder_cannot_take_the_lock() {
let first = StoreLock::acquire(&path).expect("first");
let second = StoreLock::try_acquire(&path).expect("no error");
assert!(second.is_none(), "두 번째가 잠금을 가져갔다");
drop(first);
assert!(StoreLock::try_acquire(&path).expect("no error").is_some());
}
상수가 틀리면 이 테스트가 깨진다. 잠금이 안 걸리거나, 걸린 채로 안 풀린다.
`getxattr`는 호출이 두 번이다
확장 속성을 읽는 것도 같은 방식인데, 여기엔 패턴이 하나 더 있다.
unsafe extern "C" {
fn getxattr(
path: *const c_char,
name: *const c_char,
value: *mut c_void,
size: usize,
position: u32,
options: c_int,
) -> isize;
}
값이 얼마나 큰지 모르니 버퍼를 미리 잡을 수 없다. 처음에는 크기만 물어보고, 그만큼 잡아서 다시 부른다.
// 1) 크기 조회 — value에 null, size에 0을 넘긴다
let size = unsafe {
getxattr(path_c.as_ptr(), name_c.as_ptr(), std::ptr::null_mut(), 0, 0, XATTR_NOFOLLOW)
};
if size < 0 {
return Ok(None); // 속성이 없다. 오류가 아니다
}
// 2) 버퍼를 잡고 다시 부른다
let mut buffer = vec![0u8; size as usize];
let read = unsafe {
getxattr(path_c.as_ptr(), name_c.as_ptr(),
buffer.as_mut_ptr().cast(), buffer.len(), 0, XATTR_NOFOLLOW)
};
buffer.truncate(read as usize);
이 2단계 호출은 POSIX 계열 API에서 흔한 패턴이다. readlink, sysctl, getgroups가 모두 비슷하다. 한 번 알면 다음부터 보인다.
두 번째 호출에서 크기가 달라질 수 있다는 것도 알아 둘 만하다. 그 사이에 값이 바뀌면 잘린다. 그래서 반환값으로 truncate한다.
문자열을 넘길 때
C 함수는 널로 끝나는 문자열을 받는다. Rust String은 널로 안 끝나므로 변환이 필요하다.
fn to_cstring(path: &Path) -> io::Result<CString> {
CString::new(path.to_string_lossy().as_bytes())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "경로에 널 바이트가 있다"))
}
CString::new는 문자열 중간에 널 바이트가 있으면 실패한다. 파일 경로에는 없는 게 정상이지만, 사용자 입력에서 온 값이면 확인해야 한다.
그리고 CString을 살려 둬야 한다. 포인터만 넘기고 원본을 떨어뜨리면 해제된 메모리를 가리킨다.
let path_c = to_cstring(path)?; // 살아 있다
unsafe { getxattr(path_c.as_ptr(), ...) }
이걸 한 줄로 붙여 쓰면 컴파일은 되는데 동작이 이상해진다.
// 위험 — 임시값이 바로 떨어진다
unsafe { getxattr(to_cstring(path)?.as_ptr(), ...) }
언제 크레이트를 쓰나
직접 선언하는 게 늘 나은 건 아니다. 기준은 이렇게 잡았다.
| 상황 | 선택 |
|---|---|
| 함수 한두 개, 상수 몇 개 | 직접 선언 |
| 플랫폼 분기가 많다 | 크레이트 |
| 구조체를 주고받는다 | 크레이트 |
| 예제라 의존성을 0으로 두고 싶다 | 직접 선언 + 테스트 |
구조체가 끼면 얘기가 달라진다. 필드 순서와 패딩까지 맞춰야 하고, 플랫폼마다 다르다. 그건 직접 할 일이 아니다.
정리
unsafe extern "C"로 선언만 하면 libc 함수를 부를 수 있다. 링크 설정은 필요 없다- 상수는 헤더에서 직접 확인한다. 틀려도 컴파일은 되므로 테스트가 필수다
getxattr같은 API는 크기 조회 → 버퍼 잡기 → 재호출의 2단계다CString은 포인터를 넘기는 동안 살아 있어야 한다- 함수 몇 개면 직접, 구조체가 끼면 크레이트
예제에서 의존성을 0으로 두면 받는 사람이 cargo test 한 줄로 확인할 수 있다. 그 값어치가 상수 몇 개 확인하는 품보다 크다고 봤다.