읽기와 쓰기는 (아마) 생각보다 덜 대칭적입니다

Reading and writing are less symmetric than you (probably) think

fgiesen.wordpress.com ▲ 130 댓글 30 luu

요약

파일을 읽을 때는 오류가 바로 드러나지만, 쓸 때는 버퍼링 때문에 오류가 뒤늦게 드러납니다.

fgiesen이 2015년 자신의 블로그에 쓴 글입니다. POSIX의 read와 write는 데이터를 옮기는 방향만 다른 것처럼 보이지만, 버퍼링(데이터를 모아 두었다가 한꺼번에 처리하는 방식)이 끼면 성격이 크게 달라진다고 설명합니다.

왜 중요한가

  • 쓰기 함수가 성공을 반환해도 데이터가 안전하게 저장됐다고 믿을 수 없습니다.
  • 이 문제는 C에 한정되지 않고 버퍼를 쓰는 모든 쓰기 구현에 공통으로 나타납니다.
  • 저자는 데이터를 안전하게 저장하는 정확한 절차를 아는 프로그래머가 드물다고 봅니다.

핵심 내용

  • 읽기 오류는 데이터가 앱에 전달되기 전에 생기므로, 각 계층이 오류를 보관했다가 알려 주면 됩니다.
  • fwrite는 데이터를 버퍼에 복사한 뒤 바로 성공을 반환하므로, 오류 보고는 fflush나 fclose 시점으로 밀립니다.
  • 저자는 fwrite의 반환 값을 확인해도 fflush와 fclose의 반환 값까지 확인하는 사람은 드물다고 지적합니다.
  • write가 성공해도 데이터는 커널의 버퍼 캐시에만 있을 수 있어, 저장을 보장하려면 fsync가 필요합니다.
  • 리눅스 매뉴얼은 디렉터리에도 fsync를 하라고 안내하지만, 저자는 그런 코드를 실무에서 본 적이 없다고 했습니다.

HN 반응

  • 읽기는 지연 시간에, 쓰기는 유실 방지에 민감하다는 보충이 나왔고, 동기식 fsync 모델 자체가 문제라는 비판도 이어졌습니다.
  • 제목만 보고 사람의 읽기와 쓰기, 언어 능력을 다룬 글로 짐작했다는 댓글도 여럿 나와 그쪽 주제로 토론이 번졌습니다.

댓글

30개 표시 · 전체 30개
  1. Pannoniae HN

    좋은 글이네요 :D 파고들면 토끼굴이 훨씬 더 깊어요. 읽기 쪽 버퍼링은 거의 항상 이득이지만, 읽기는 쓰기보다 지연 시간에 훨씬 민감한 경우가 많거든요. 그래서 mmap으로 버퍼 크기를 키우거나 프리페치를 더 많이 하면 지연 시간이 늘어서 오히려 성능이 나빠지는 역설이 생길 수 있어요. 반대로 버퍼 크기를 줄여도 호출이 많아져서 똑같이 나빠질 수 있고요. 그러니 읽기에서 처리량과 지연 시간의 최적 비율을 짜내려면 워크로드마다 튜닝해야 해요... 아니면 버퍼 크기를 몇 개 바꿔 보고 더 나은 걸 고르든가요.

    쓰기는 정반대예요. 보통 RAW(read-after-write)가 아니라 "쏘고 잊는" 방식이라 지연 시간은 별로 신경 쓰지 않지만, 쓴 데이터가 실제로 제대로 도착하는지는 엄청나게 신경 써야 하죠... 여기도 서로 맞서는 두 변수가 있어요. 쓰기를 많이 모아서 처리할수록 빨라지지만, 그만큼 torn write(쓰다 만 상태로 남는 쓰기)나 데이터 손실 가능성도 커지거든요. 게다가 읽기와 달리 쓰기는 애플리케이션 수준에서 멱등이 아니라서, 도중에 크래시가 나더라도 의미 있는 양의 데이터를 원자적으로 써서 찢어지지 않게 해주는 로직이 있으면 이상적이에요.

    현실에서는 이런 걸 실제로 하는 사람이 거의 없지만, 생각해 보면 재밌잖아요 :D

  2. Insimwytim HN

    저는 글을 읽지도 않고 댓글부터 보는 죄를 지었습니다. 사람이 글을 읽고 쓰는 이야기인 줄 알았고, 첫 문단에서 점점 더 헷갈렸어요.

    처음엔 은유로 풀어 보려고 했는데, 앞뒤가 맞지 않더군요.

  3. psdndown HN

    저도요. 천천히 읽는다고 칭찬받아야 한다는 흐뭇한 인상이 있었는데, 같은 깨달음이 오면서 완전히 뒤집혔습니다.

  4. plasticchris HN

    일리가 있네요. 읽는 쪽은 다음으로 나아가려면 그게 필요하지만, 쓰는 쪽은 쓰고 나면 끝이니까요.

  5. Veserv HN

    그 대부분은 리눅스를 비롯한 유닉스 계열 시스템이 쓰는 동기식 연산이라는 근본적으로 형편없는 I/O 모델 때문입니다. 거기에 하드웨어 제조사들이 그 형편없는 모델이 괜찮아 보이도록 꼼수를 쓴 결과이기도 합니다.

    현대의 고속 하드웨어는 본질적으로 전부 배치 방식이고 비동기입니다. 고성능을 내는 방법은 연산을 배치로 묶어 비동기로 제출하고, 비동기 완료를 배치 단위로 기다리는 것입니다. 배치는 처리 효율이 높을 뿐 아니라, 문맥을 더 많이 알게 된 "똑똑한" 하드웨어가 필요하면 연산을 더 효율적으로 스케줄링할 수 있게 해 줍니다.

    필요한 읽기를 미리 많이 묶어서 보내면 추측성 읽기 버퍼링이 그만큼 덜 필요해집니다. 순차 읽기를 할 것이라고 하드웨어가 짐작하게 두는 대신, 실제로 원하는 정보의 "전부 또는 대부분"을 알려 줄 수 있기 때문입니다. 읽기가 비동기라면(하드웨어는 원래 그렇게 동작합니다) 추측성 읽기도 명시적으로 발행할 수 있습니다. 1GB 파일을 읽을 생각이라면 사용자 공간 I/O 라이브러리가 1MB짜리 읽기 1,000개를 쏘고, "다음"에 필요한 부분이 돌아올 때까지만 기다렸다가 처리를 이어가면 됩니다. 1GB를 동기식으로 한 번에 읽고 나서야 처리를 시작하면, 하드웨어 I/O는 병렬로 처리할 수 있는데도 읽기 전체가 끝날 때까지 처리가 미뤄집니다. 그와 대조됩니다.

    결국 (하드웨어의 기본 효율 블록 크기를 넘어서) 추측성 읽기 버퍼링이 의미 있게 필요한 경우는 이제 두 가지뿐입니다. 접근 패턴을 제대로 이해하지 못해서 배치로 묶지 못하는 경우, 아니면 데이터에 따라 달라지는 로드 패턴이 공교롭게도 꾸준히 추측성 읽기 버퍼 안에서 해결되는 경우입니다. 예를 들어 8바이트를 읽었더니 원하는 레코드가 있는 파일 오프셋이 나오는데, 그 레코드가 거의 언제나 근처에 있는 경우죠.

    쓰기에서 원하는 것은 데이터 기준 또는 시간 기준 플러시 전략입니다. 사실 이건 버퍼링을 하는 거의 모든 경우에 써야 하는 플러시 전략입니다. 데이터 기준 플러시는 효율적인 배치를 위해 존재합니다. 작은 연산을 잔뜩 발행하면 오버헤드에 죽으므로, 연산을 버퍼에 모아 더 큰 연산으로 합쳐서 오버헤드가 분산되어 사라지게 합니다. 그런데 데이터가 충분히 생성되지 않아 데이터 기준 플러시 조건에 닿지 못하면 지연 시간이 커지거나 한없이 길어질 수 있습니다. 이를 해결하려면 처리량이 낮을 때도 허용할 수 있는 최악의 지연 시간을 함께 목표로 잡습니다. 그러면 처리 시간이 시간 기준 플러시 조건에 닿을 만큼 길었으므로, 오버헤드가 처리 시간 대비 비율로 "분산"되어 사라집니다. 실질적으로는 완료까지 걸리는 시간의 N%를 오버헤드에 할당하는 셈이고, 이 오버헤드는 데이터가 충분히 생겨서 분산되거나 시간이 충분히 흘러서 분산될 때 발동합니다.

    쓰기가 비동기이면 데이터 손실 문제도 해결할 수 있습니다(하드웨어가 협조한다면요). 쓰기를 쏘고 나서 하드웨어가 그 쓰기의 영속화를 알려 줄 때까지 기다립니다. 그 비동기 쓰기에 대한 비동기 완료 신호를 보기 전에는 영속화되었다고 가정할 수 없습니다. 순서는 쓰기 배리어를 발행해 보장합니다. 배리어는 비동기 큐에서 앞선 쓰기가 모두 완료를 알린 뒤에 자신의 완료를 알리라고 하드웨어에 지시합니다. 원한다면 이를 다른 계층으로도 확장해서 다양한 수준의 "영속성"을 감지하게 할 수 있습니다.

    안타깝게도 리눅스를 비롯한 유닉스 계열 시스템은 효율적인 I/O와는 정반대인 지독하게 끔찍한 fsync() 모델을 씁니다. 쓰기 배리어를 발행하고 완료 알림을 받는 동안 후속 쓰기를 계속 비동기로 발행할 수 있는 게 아니라, 앞선 I/O가 모두 끝날 때까지 동기식으로 기다립니다. 기다리는 동안 실행이 직렬화되니 순서를 보장하거나 데이터를 영속화하려는 시도는 무시무시하게 비싸집니다. 너무 끔찍하다 보니 하드웨어 제조사들은 데이터가 영속화된 시점을 거짓으로 알려 fsync()가 더 빨리 끝나게 하면 처리량 벤치마크 점수를 크게 올릴 수 있다는 걸 깨달았습니다. 서로 다른 속성인 데이터 영속화 지연 시간 때문에 처리량 벤치마크가 망가지는 일을 피하려는 겁니다. 비동기 모델에서는 연산과 쓰기 배리어를 연달아 쏘기만 하면 되고(하드웨어는 원래 그렇게 동작하도록 설계되었습니다), 읽기/쓰기 처리량과 데이터 영속화 지연 시간을 서로 뒤섞지 않고 각각 평가할 수 있습니다. 이쪽이 훨씬 더 유용하고 효율적입니다.

  6. 1317 HN

    아, 언어와 뇌가 어떻게 작동하는지에 관한 글인 줄 알았어요. 솔직히 그쪽 글이 더 재밌을 것 같네요. 죄송해요.

  7. prmph HN

    맞아요, 저도 그런 줄 알았어요. 아쉽네요.

    누가 대신 그 글을 올려 줬으면 좋겠어요, ㅋㅋ 저는 어떤 언어는 거의 다 이해하고 읽을 수 있는데 말은 못 하는 이유를 이야기해 보고 싶거든요.

  8. todowhatexactly HN

    산출은 수용과 다릅니다.

    자전거를 알아보는 건 쉽지만 그리는 건 어렵다는 걸 비교해 보세요.

    연필이라는 도구의 문제만은 아닙니다. 물론 그것도 한몫하지만요. 머릿속에 떠올린 대로 완벽한 선을 그릴 수 있다 해도, 훈련받은 화가가 아니면 자전거나 빛의 표현 같은 것이 사실적으로 나오지 않을 겁니다.

  9. chrisweekly HN

    "Broca and Wernicke"(브로카와 베르니케)를 검색해 보세요. 언어를 만들어 내는 부위와 처리하는 부위가 뇌에서 서로 다르다는 걸 알 수 있습니다.

  10. kekebo HN

    제가 가진 가장 좋은 참고 자료는 아닐 수도 있지만, 언어를 베르니케나 브로카 같은 몇몇 뇌 영역으로 환원하는 관점은 최근 바뀌고 있는 것 같습니다(연구는 아직 더 필요해 보이지만요)[0].

    [0] https://psycnet.apa.org/fulltext/2024-11707-001.html

  11. chrisweekly HN

    물론이죠. 그냥 시작하기에 괜찮은 지점이라는 뜻이었어요.

  12. tbrownaw HN

    아니면 어떤 주제에 대해 쓸모 있는 글을 쓰려면, 쓰인 글을 읽고 이해하는 것보다 더 높은 전문성이 필요하다는 내용일 수도 있고요.

  13. verdverm HN

    아니면 글을 더 잘 쓰고 싶으면 더 많이 읽으라는 얘기거나요.

  14. alsetmusic HN

    당신께 선물이 될 만한 책이 있어요. 영어의 역사와 발달을 다룬 책이에요.

    Highly Irregular: Why Tough, Through, and Dough Don’t Rhyme—And Other Oddities of the English Language

  15. vatsachak HN

    CPU 캐시 수준에서도 마찬가지라서, 아마 끝까지 그대로 내려갈 겁니다.

    예를 들어 Jon G는 제인 스트리트에서 한 발표에서, 원인을 알 수 없던 성능 버그가 나중에 코어들이 같은 캐시 라인을 두고 다투는 탓이었다고 밝혀진 일을 이야기했습니다.

    https://youtu.be/tND-wBBZ8RY?is=624w3YoDN2QYBWuD

  16. rwmj HN

    false sharing(거짓 공유)은 꽤 잘 알려진 거 아닌가요? 저는 대학에서 배웠어요. 리눅스 "dwarves" 도구가 만들어진 이유가 바로 그거예요. 구조체 필드 정렬과 묶음을 캐시 라인 크기와 비교해 보고, 서로 다른 코어가 쓰는 인접한 두 필드가 실수로 같은 캐시 라인에 들어가지 않게 확인할 수 있거든요.

  17. rwmj HN

    읽기, 쓰기, 동기화를 다루는 논의라면 쓰기 증폭(읽기-수정-쓰기 사이클)에 대해서도 이야기하고 싶습니다. 리눅스에서는 파일의 일부만 동기화하는 데 유용한 sync_file_range 시스템 콜도 다루고 싶고요.

  18. zahlman HN

    (2015)

    제가 왜 둘이 "(아마) 대칭적이라고 생각"해야 하는지 모르겠어요. 저는 코드를 읽는 것과 코드를 쓰는 것을 직접 겪어 봤는데, 확실히 전혀 다른 과정이잖아요.

  19. zokier HN

    쓰기 오류와 fsync를 둘러싸고는 재미있는 일이 많습니다. 한 예가 이른바 "fsyncgate"인데, postgresql이 fsync 문제를 만났던 사건입니다: https://news.ycombinator.com/item?id=20491965

    그 뒤로 커널에 변경이 몇 가지 있었던 것 같은데, 정확히 뭐였는지는 기억나지 않네요.

  20. 6LLvveMx2koXfwn HN

    저는 '뇌'를 생각했어요. 뭔가를 읽으면 그냥 그렇고, 뭔가를 쓰면 쾅, 그게 머리에 박히잖아요!

  21. benibela HN

    저는 사람의 속도를 생각했어요.

    읽기는 아주 빨리 할 수 있는데 쓰기는 오래 걸리잖아요.

    말도 꽤 짜증 나요. 사람이 말하는 것보다 제가 읽는 게 더 빠르니까요. 그런데 최악은 강의 중에 칠판에 글씨를 쓰기 시작하는 거예요. 그러면 그 사람이 느릿느릿 쓰는 걸 지켜보고 있어야 하거든요.

  22. mightyham HN

    데이터가 저장소에 도달했더라도, 거기에 접근하는 데 필요한 메타데이터가 아직 기록되지 않았을 수 있으므로 실제로 보인다는 뜻은 아닙니다.

    저자가 여기서, 이 fsync의 함정은 새로 만든 파일에만 해당하고 기존 파일을 갱신할 때는 해당하지 않는다고 분명히 밝혀야 합니다.

  23. cryptonector HN

    사람이 읽고 쓰는 얘기를 기대했는데 POSIX I/O API에 관한 글이었네요. 나쁘지 않습니다. 저라면 한발 더 나아가서, mmap()은 읽기에는 좋지만 쓰기에는 좋지 않다는 점과 그 이유까지 짚겠습니다.

  24. tbrownaw HN

    맞아요, 뭔가를 읽는다고 내 캐시가 무효화되지는 않죠. 다른 모든 사람의 캐시도 마찬가지고요.

  25. adrian_b HN

    쓰신 그대로의 뜻인지, 반대로 말씀하시려던 건지 모르겠습니다. 하지만 큰 파일을 읽을 때 비어 있는 메모리 페이지가 없으면, 사용자나 다른 사용자가 접근한 파일의 데이터를 캐싱하고 있던 디스크 캐시의 메모리 페이지가 쫓겨나게 됩니다.

    고성능 애플리케이션에서는 어떤 페이지를 무효화할지 커널이 임의로 고르게 두면 안 됩니다. 메모리 맵 파일에는 madvise를, read/write로 접근하는 파일에는 posix_fadvise를 적절한 인자와 함께 써서, 더 이상 필요 없는 캐시 메모리 페이지가 데이터를 더 읽기 전에 충분히 일찍 해제되도록 해야 합니다.

  26. tbrownaw HN

    공간 때문에 다른 걸 쫓아낼 수는 있죠. 하지만 그 내용이 무효가 되는 건 아니에요.

  27. thaumasiotes HN

    캐시를 지우는 것과 무효화하는 것은 뭐가 다른가요?

  28. tbrownaw HN

    한쪽은 데이터가 없는 거고, 다른 쪽은 틀린 데이터를 갖고 있는 거예요.

  29. thaumasiotes HN

    하지만 "캐시 무효화"는 그런 건 상관하지 않아요. 데이터를 찾았는데 맞다고 믿고 틀린 데이터를 읽는 문제는 "메모리 손상"이라고 부르죠. "캐시 무효화"는 캐시에 있던 데이터가 더 이상 없는 상황이고요. 이때 생기는 문제는 데이터가 부정확해지는 게 아니라 데이터를 찾는 데 더 오래 걸린다는 거예요.

    캐시에서 항목을 제거하는 것과, 그 항목의 근거가 되는 데이터를 바꾸는 것은 정확히 같은 효과를 낸다는 걸 아실 거예요.

  30. chrchang523 HN

    (2015)

Hacker News에서 보기 ↗