이제 러스트를 사용한지 9개월 정도가 되가는 러스트 뉴비입니다. 주말동안 Art of Multiprocessing Programming 에 나온 자바 코드를 러스트 코드로 옮기다가 &mut 를 두 개 이상의 스레드에서 동시에 사용하기 어려운 문제를 맞닥뜨렸습니다. 이를 기존 Arc, Lock, Mutex 라이브러리 없이 해결하려 여러 뻘짓을 하다가 이 글을 쓰게 되었습니다^^ 재밌게 읽어주세요~
제가 어제 구현을 하려고자 했던 것은 병렬 처리가 가능한 여러 종류의 Lock을 실제로 구현을 해보는것이었습니다. Lock 구현에 대한 pseudocode는 이미 책에 나와 있었기 때문에 러스트로 구현을 하는것은 어렵지 않았습니다. 다만 이를 실제로 테스트 해보는건 전혀 다른 문제였습니다. 두 개의 스레드 간에서 Lock 하나를 공유해야 하고 Lock의 함수들을 부르기 위해서는 lock에 대한 &mut 이 필요한 상황입니다. 그렇다면 새로 spawn 한 스레드로 Lock을 move 할수도 &mut Lock 만 넘길수도 없는 상황에 처했습니다(Lock 코드는 그대로 가져올 필요가 없기에 Lock 역할을 하는 A라는 가상의 struct를 하나 만들었습니다!) 코드로는 다음과 같은 상황입니다.
#[repr(C)]
struct C {
inner: u64,
}
#[repr(C)]
struct A {
id: String,
c: C,
}
fn main() {
let mut a = A::new();
unsafe {
std::thread::spawn(move || {
a.test() //여기서 이미 a는 move되었습니다.
});
}
a.test(); //이미 move 된 a를 다시 호출할수 없습니다. compile error!
}
위에 언급드린대로 Art of Multiprocessing Programming 에서 공부하는 챕터가 기존 library 에 존재하는 arc, lock, mutex 들을 사용하지 않는거니 이것들을 사용할수도 없습니다. 이를 해결하기 위해 제가 생각한 방법은 "포인터를 직접 움직이자"입니다.
모두 아시겠지만 포인터는 그저 usize 값일뿐입니다. 즉, Lock에 대한 포인터는 usize 이기 때문에 copy, clone 이 자유롭습니다. 그렇다면 이 포인터를 새로운 스레드로 넘긴 다음 이 포인터를 통해 당시 Lock 오브젝트를 만들면 원래 Lock 과 같은 오브젝트가 만들어지는거 아닐까?라는 생각을 하게 되었습니다.
fn main() {
let mut a = A::new();
let a_ptr: *const A = &a;
let a_ptr_clone = a_ptr as usize;
unsafe {
std::thread::spawn(move || {
let a_ptr = a_ptr_clone;
});
}
a.test();
}
일단 여기까지는 무리가 없어 보입니다. 그렇다면 이제 저 a_ptr 를 가지고 다시 저희가 원하는 A를 만들어야 합니다. std::ptr를 찾아본결과 read라는 함수가 있군요. Documentation(https://doc.rust-lang.org/std/ptr/fn.read.html)을 설명이 간단히 "Reads the value from src
without moving it. This leaves the memory in src
unchanged."이라고 나와있습니다. 설명이 충분하지는 않지만 일단 구현을 한다음 확인을 하기 위해 새로 만들어진(?) A의 address를 원래 A의 address와 비교해보겠습니다.
fn main() {
let mut a_0 = A::new();
let a_0_ptr: *const A = &a_0;
println!("a_0 has memory address at a_1_ptr: {:p}", a_0_ptr);
let a_ptr_clone = a_0_ptr as usize;
unsafe {
std::thread::spawn(move || {
let a_ptr = a_ptr_clone as *const A;
let a_1 = std::ptr::read(a_ptr);
let a_1_ptr : *const A= &a_1;
println!("a_1 has memory address at a_1_ptr: {:p}", a_1_ptr);
});
}
a_0.test();
}
a_0 has memory address at a_1_ptr: 0x7ffee60ea418
a_1 has memory address at a_1_ptr: 0x70000112ec08
음 a_0과 a_1의 주소가 다르군요. 뭐가 문제일까요? 다시 std::ptr::read를 자세히 읽어보겠습니다.
read
creates a bitwise copy of T
, regardless of whether T
is Copy
. If T
is not Copy
, using both the returned value and the value at *src
can violate memory safety. Note that assigning to *src
counts as a use because it will attempt to drop the value at *src
.
아하! 이런 중요한 부분은 제가 놓쳤었습니다. 이 설명에 의하면 read는 C의 memcpy 를 내부적으로 사용해서 원래있던 T의 copy를 하고 있었네요. 즉, read를 통해서 원래 포인터 위치에 있는 오브젝트를 만든것이 아니라 이를 복사해서 새로 만든것에 불과합니다. 그러면 일단 저희의 첫번째 시도는 실패입니다.
즉, 위 문제에서 깨달은 사실은 단순 std::ptr::read로는 저희의 문제가 해결되지 않는 상황이라는것입니다. 그런데 갑자기 의문점이 하나 생겼습니다. struct A를 자세히 보시면 id라는 Field가 type 이 String 인걸 알수 있습니다. String..?! 이라면 실제로는 Vec 이니까 heap에 저장되고 그렇다면 String 은 사실 저 Vec을 가리키는 pointer만 들고 있을텐데...? 그렇다면 새로 만들어진 A와 원래 A의 id 주소는 같지 않을까? 라는 생각이 들었습니다. 그러나 위 결과에서 보셨듯이 A struct 의 첫번째 field 가 id이므로 A의 주소가 곧 id의 주소일테고 그렇다면 새로 만들어진 A와 원래 A의 id 주소는 같지 않다는 결론이 나게 됩니다. 왜 이런 상황이 발생할까요?
네 사실 생각해보면 자명한 결과입니다. std::ptr::read 는 memcpy 를 내부적으로 사용하게 되고 memcpy는 Vec의 경우 bytes to bytes copy를 하기 때문에 새로운 Vec 즉, 새로운 String이 만들어진 상황이죠. 그러면 저희가 원하는 copy는 어떤 copy 인가요? 네 맞습니다. 단순히 포인터에 대한 copy 를 해야 하는 상황이죠. 그렇다면 만약 stack 에 있는 포인터의 wrapper가 있고 이 wrapper 를 memcpy 하면 실제 data가 아닌 pointer 값이 copy 되어서 저희가 원하는 상황입니다! 이를 통해 알수 있는건 만약 std::ptr::read를 사용한다면 실제로 메모리를 유지하고 싶은 field를 또 하나의 wrapper로 감싸면 된다는 사실입니다. 이 wrapper는 그냥 struct 일수도 있지만 Box 를 한번 사용해보고 싶군요. Box 로 A안의 C 필드를 wrapping 해보겠습니다.
#[repr(C)]
struct A {
id: String,
c: Box<C>,
}
impl A {
fn new() -> Self {
Self {
// id: 0,
id: nanoid::nanoid!(10),
// c: C { inner: 0 },
c: Box::new(C { inner: 0 }),
}
}
fn test(&mut self) {}
}
a_0 has memory address at a_1_ptr: 0x7ffee8908368
a_0.id has memory address at: 0x7ffee8908368
a_0.c has memory address at: 0x7fdab8c05ac0
a_1 has memory address at a_1_ptr: 0x7000043c2b58
a_1.id has memory address at: 0x7000043c2b58
a_1.c has memory address at: 0x7fdab8c05ac0
성공입니다! 예상했던 대로 a_0 와 a_1의 주소는 다르지만 a_0.c 와 a_1.c의 주소는 같은걸 확인했습니다! 즉, share하고자 하는 object 를 wrapper로 한번 감싸면 저희가 목표했던 바를 이룰수 있게 되었네요!
근데 뭔가 이상하지 않으신가요? 원래 하고자 했던건 그저 A에 대한 &mut 를 스레드 두 개에서 공유하는건데 이상한 방식으로 여기까지 와 버렸네요..여기까지 이렇게 오게 된 이유를 다시 한번 생각해보면
즉, 만약 어떻게든 &mut를 스레도 사이에서 넘길수 있다면 조금 더 깔끔하게 문제가 해결될수 있다는 의미입니다! 여기서 저희가 위에서 이상한 짓을 하면서 배운 것 하나를 사용하면 깔끔하게 문제가 해결됩니다. 그건 바로 wrapper를 사용하는 개념입니다. 만약 &mut를 wrapper로 감싸고 이 wrapper에 대하여 Send 구현 한 다음, 스레드 사이에서 이동을 한후 wrapper에서 꺼내 사용하면 간단히 해결됩니다. 다만 조심해야 할 부분은 lifetime으로 인해 &mut를 스레드 사이에서 이동할수는 없기 때문에 *mut 로 이동을 하고 다시 &mut 로 coercion 을 해주면 된다는 사실입니다. 코드는 다음과 같습니다.
struct Wrapper(*mut A);
unsafe impl Send for Wrapper {}
fn main() {
let mut a = A::new();
println!("{:p}", &mut a);
let wrapper = Wrapper(&mut a as *mut A);
std::thread::spawn(move || unsafe {
let a = &mut *(wrapper.0);
println!("{:p}", a);
a.test()
});
a.test();
}
0x7ffee18b1428
0x7ffee18b1428
보시다시피 단순히 mut A를 wrapper감싼다음 wrapper을 스레드 사이에서 움직이고 다시 mut A값을 &mut 로 type coersion 해줌으로써 저희가 해결하고자 하는 문제를 매우 간단히 해결헀습니다...!
혹시나 글에 틀린 부분이 있다면 pandawithcat@gmail.com으로 연락주시면 감사하겠습니다~
ㅋㅋㅋㅋ 시도는 정말 재밌게 봤습니다만, 어느 상황에서든 두 가변 참조자를 만드는 건 UB인 걸로 알고 있습니다..