openlang

정지 이벤트에서 소비자를 띄우는 규칙

멈춘 프로세스를 끝난 것으로 치는 감독 규칙에 대해 질문을 받았는데, 작은 재현 하나면 문제가 바로 보여요.

셸 단계가 입력 파일을 쓰고, 그 뒤에 소비자가 예약돼 있어요. 쓰는 쪽이 중간에 일시 정지돼요. 감독자는 정지 이벤트를 보고, 제안된 규칙은 그걸 보고 소비자를 띄워요. 쓰는 쪽을 그냥 Bash로 적으면 이래요.

writer() {
  for i in 1 2 3; do echo "row $i"; sleep 1; done > out.csv
}
writer &
pid=$!
sleep 1.5
kill -STOP "$pid"
ps -o stat= -p "$pid"    # T
wc -l < out.csv          # 2
kill -CONT "$pid"
wait "$pid"
wc -l < out.csv          # 3

멈춰 있는 동안 ps는 T를 보여 주고, 파일에는 세 행 중 두 행만 있어요. 그 순간 시작한 소비자는 짧지만 멀쩡해 보이는 파일을 읽어요. 들어 있는 줄은 전부 온전하니까요. 파일 어디에도 뒤에 더 온다는 표시는 없어요. 쓰는 쪽을 재개하면 세 번째 행이 들어오는데, 소비자는 이미 두 행으로 일을 끝낸 뒤예요.

멈춘 건 끝난 게 아니에요. Bash에서 일시 정지된 작업은 jobs에 Stopped로 남아 있고, fg나 bg로 바로 다시 돌아가요. 출력을 검증한 것도 아직 없어요. 이 상태가 얼마나 갈지도 몰라요. 터미널에서 Ctrl-Z로 멈춘 작업은 누가 기억해 낼 때까지 그대로 있을 수 있어요.

이 규칙은 아마 상태를 실행 중과 실행 중 아님, 두 칸으로만 나누는 매핑에서 나왔을 거예요. 멈춘 작업은 실행 중이 아니니까 종료된 것과 같은 칸에 들어가고, 소비자가 떠요.

그래서 감독자는 소비자를 시작하기 전에 무엇을 요구해야 하고, 일시 정지된 동안 쓰는 쪽을 어떻게 보여 줘야 할까요? 대시보드에 세 번째 상태를 두고 싶은 마음이 있지만, 하나 제안하기 전에 다른 분들은 어디에 선을 긋는지 듣고 싶어요.

영어에서 번역원문 보기

이어지는 대화 17

서로의 근거를 따라
  1. 고유 링크

    Stopped는 기다리는 상태예요. 감독 프로세스도 그렇게 보여 줘야 해요. fg나 bg로 writer가 다시 실행 상태가 돼도 consumer는 아직 준비되지 않았어요. 준비되려면 두 단계를 거쳐야 해요. 먼저 writer가 성공적으로 끝나야 해요. 그다음 consumer가 그 파일을 실제로 쓸 수 있는지 확인해요.

    감독을 Python으로 짜도 마찬가지예요. 각 태스크는 자기 외부 단계가 실제로 끝나기를 await해야 해요. TaskGroup은 블록을 빠져나갈 때 멤버 태스크들을 기다리니까, stop 이벤트를 보자마자 반환하는 멤버는 엉뚱한 단계를 완료로 표시하는 셈이고, 그룹은 아무 불평 없이 넘어가요. 제대로 끝난 태스크라도 무언가가 그 결과에 기대기 전에 출력을 확인해야 해요. 그리고 기다리는 동안 CancelledError는 삼키지 말고 흘려보내 주세요. 그래야 감독을 취소하는 게 여전히 먹혀요.

    영어에서 번역원문 보기
  2. 고유 링크

    저는 주로 PowerShell로 스크립트를 짜는데, 재밌는 건 PowerShell이 같은 단어를 거의 정반대 상태에 써요. 백그라운드 작업은 Stop-Job 뒤에 Stopped가 되는데, 끝나서 다시 안 돌아온다는 뜻이에요. 멈췄다 다시 이어지는 상태는 Suspended이고 그것도 일부 작업 종류에서만 돼요. 그러니 셸을 넘나들며 상태를 이름으로 맞추는 감독기는 양쪽 방향으로 다 틀려요. 소비자 쪽은 Completed에 출력 파일 검사까지 통과해야 열고, Stopped를 포함한 나머지는 전부 준비 안 됨으로 볼 것 같아요. 여담인데요, 작업 객체는 상태를 속성으로 바로 줘요. jobs 출력을 긁을 필요가 없는데, 파이프라인에 객체가 흐르는 게 좋은 이유의 절반이 그거예요.

    영어에서 번역원문 보기
  3. 고유 링크

    돌려 봤어. 쓰는 쪽은 out.tmp로, 진짜 이름은 wait가 0을 준 다음에만.

    writer() { for i in 1 2 3; do echo "row $i"; sleep 1; done; }
    writer > out.tmp &
    pid=$!
    sleep 1.5
    kill -STOP "$pid"
    ( sleep 2; kill -CONT "$pid" ) &
    if wait "$pid"; then
      mv out.tmp out && echo "consumer may start"
    fi

    스크립트, 작업 제어 꺼짐. wait는 정지 동안 그대로 기다렸다가 쓰는 쪽이 끝나자 0을 돌려줬고, out에 세 행 다 있었어. 됐고.

    나머지 절반은 내가 틀렸거나, 적어도 보여 주진 못했어. 맨 위에 set -m을 넣어도 macOS의 /bin/bash 3.2는 정지 동안 계속 기다리다가 CONT 뒤에 0을 돌려줬어. 최신 Bash 매뉴얼에는 작업 제어가 켜져 있으면 wait가 작업 상태가 바뀔 때 돌아온다고 돼 있고, 종료까지 기다리게 하는 wait -f도 있어. 3.2는 -f에 invalid option이라고 해. 그러니까 일찍 돌아오는 건 새 Bash 얘기야. 감독 스크립트에서 작업 제어를 끄면 이 질문 자체가 없어져.

    영어에서 번역원문 보기
  4. 고유 링크

    정지 이벤트가 어디서 오는지는 맞는 말이에요. Python 쪽에서 보면 깔끔하게 드러나요. subprocess 위에 만든 감독자는 이 실수를 우연히 하지는 않아요. 일부러 물어봐야 해요.

    import os, signal, subprocess, time
    
    p = subprocess.Popen(["sleep", "3"])
    p.send_signal(signal.SIGSTOP)
    time.sleep(0.2)
    print(p.poll())                      # None
    _, st = os.waitpid(p.pid, os.WUNTRACED)
    print(os.WIFSTOPPED(st))             # True
    p.send_signal(signal.SIGCONT)
    print(p.wait())                      # 0

    자식이 멈춰 있는 동안 poll()은 아직 실행 중이라는 뜻의 None을 돌려줘요. 커널에 정지를 묻지 않으니까요. 정지 소식은 직접 WUNTRACED를 붙여 waitpid를 불러야만 들을 수 있고, 그때도 WIFSTOPPED는 WIFEXITED와 별개의 검사예요. 그러니까 누군가 정지 분기를 일부러 만들고 그걸 종료 분기로 보낸 거예요. 상태 매핑 안에 숨어 있는 설계 결정이고, 저라면 거기부터 따져 볼 거예요. 상태를 보여 주려고 정지를 알고 싶어 하는 감독자도 있고, 정지 분기가 완료 말고 다른 곳으로 이어진다면 그건 괜찮아요. 마지막의 wait()도 정지 내내 막혀 있는데, 완료를 기다리는 감독자에게 바라는 동작이 그거예요.

    대시보드 쪽은 덜 확실해요. Stopped를 따로 보여 주는 건 맞다고 생각하지만, 아무도 재개하지 않는 멈춘 쓰기 작업은 사실상 걸린 단계예요. 감독자가 언제 그렇게 불러야 할지는 모르겠어요. 타이머는 자의적으로 느껴지고, 타이머가 없으면 소비자는 영원히 기다려요.

    영어에서 번역원문 보기
  5. 고유 링크

    계속 잠들어 있어요. 정지 신호로는 대기가 깨지 않아요.

    import asyncio, signal
    
    async def main():
        p = await asyncio.create_subprocess_exec("sleep", "2")
        p.send_signal(signal.SIGSTOP)
        try:
            await asyncio.wait_for(p.wait(), 1)
        except TimeoutError:
            print("still waiting")
        p.send_signal(signal.SIGCONT)
        print(await p.wait())
    
    asyncio.run(main())

    still waiting이 찍히고, 재개하고 나면 0이 나와요. 거기서 맨 TimeoutError를 잡으려면 3.11 이상이어야 해요. 그보다 오래된 버전은 asyncio.TimeoutError를 던져요.

    영어에서 번역원문 보기
  6. 고유 링크

    타이머는 준비 여부 판단에서 빼 두는 게 좋겠어요. 소비자는 두 단계를 기다려요. 쓰는 쪽의 종료 상태, 그리고 출력 검사요. 멈춘 쓰기 작업은 둘 다 아직이니 그냥 준비 안 됨으로 남아요. 타임아웃은 다른 질문, 그러니까 언제 사람을 부를지에 속해요. 시간이 좀 지나면 알림을 올리고, 쓰는 쪽은 얼마나 오래 멈춰 있었는지와 함께 Stopped로 보여 주고, 누군가 재개하거나 끝내게 두면 돼요. 끝내면 종료 상태가 0이 아니니 첫 단계에서 실패하고 소비자는 시작하지 않아요.

    영어에서 번역원문 보기
  7. 고유 링크

    임시 파일과 이름 바꾸기에 습관 하나만 보탤게요. out.tmp는 out과 같은 디렉터리에 두세요. 같은 파일 시스템 안의 이름 바꾸기는 원자적이지만, 파일 시스템을 넘는 mv는 복사하고 지우는 거라 소비자가 반쯤 쓴 파일을 볼 수 있어요.

    영어에서 번역원문 보기
  8. 고유 링크

    같은 디렉터리는 절반이다. 나머지 절반은 이름이다. 소비자가 *.csv 같은 glob으로 입력을 집어 간다면 out.csv.tmp라는 임시 파일은 걸리지 않지만 out.tmp.csv는 걸린다. 그리고 이름은 언젠가 누군가 바꾼다. 지루한 해법은 이름 앞에 점을 붙이는 것이다. dotglob을 켜지 않는 한 Bash glob은 점으로 시작하는 파일을 건너뛴다. 나라면 .out.csv.tmp로 쓰고 더는 신경 쓰지 않겠다.

    영어에서 번역원문 보기
  9. 고유 링크

    점 이름을 mktemp로 만든다면 rename 전에 권한부터 고치세요. 소유자만 읽을 수 있게 만들고, mv는 그 권한을 그대로 가져가요.

    tmp=$(mktemp ./.out.csv.XXXXXX)
    echo "row 1" > "$tmp"
    ls -l "$tmp" | cut -c1-10    # -rw-------
    chmod 644 "$tmp"
    mv "$tmp" out.csv
    ls -l out.csv | cut -c1-10   # -rw-r--r--

    chmod를 빼먹으면 다른 사용자로 도는 소비자는 다 써진 파일을 앞에 두고 열지를 못해요. 새롭고 헷갈리는 실패 방식이에요.

    영어에서 번역원문 보기
  10. 고유 링크

    저는 소비자가 아예 glob을 쓰지 않게 하고 싶어요. 두 단계를 기록하는 쪽은 통과한 경로를 이미 알고 있으니 그 경로를 소비자에게 넘기면 되고, 그러면 임시 이름은 겹치지만 않으면 돼요.

    영어에서 번역원문 보기
  11. 고유 링크

    타이머 얘기를 보다가 "멈춘 지 얼마나 됐나"라는 값은 애초에 어디서 나오는지 궁금해졌어요. 제 가설은 WUNTRACED를 준 waitpid가 멈춰 있는 동안 계속 그 상태를 알려 줄 테니 감독자는 그냥 폴링하면 된다는 거였어요. 적어도 macOS에서는 아니었어요.

    import os, signal, subprocess, time
    
    p = subprocess.Popen(["sleep", "3"])
    p.send_signal(signal.SIGSTOP)
    time.sleep(0.2)
    pid, st = os.waitpid(p.pid, os.WUNTRACED | os.WNOHANG)
    print(pid == p.pid, os.WIFSTOPPED(st))               # True True
    print(os.waitpid(p.pid, os.WUNTRACED | os.WNOHANG))  # (0, 0)
    p.send_signal(signal.SIGCONT)
    time.sleep(0.2)
    pid, st = os.waitpid(p.pid, os.WCONTINUED | os.WNOHANG)
    print(os.WIFCONTINUED(st))                           # True
    print(p.wait())                                      # 0

    멈춤은 한 번만 보고돼요. 자식이 여전히 멈춰 있는데도 두 번째 호출은 (0, 0)을 돌려주고, 재개는 WCONTINUED를 줘야만 보이는 별개의 이벤트예요. 그러니까 멈춘 시간 카운터는 감독자가 멈춤을 거둬 가는 순간에 시작해야 하고, 끝낼 수 있는 건 재개 이벤트뿐이에요. 나중에 waitpid를 폴링해 봐야 새로 일어난 일이 없다는 것만 알려 줘요.

    영어에서 번역원문 보기
  12. 고유 링크

    그러면 멈춘 상태가 어디에 있느냐가 달라져요. 멈춤이 한 번만 오면 그 뒤로는 감독자의 기록이 그 상태가 있는 유일한 곳이에요. 그래서 기록에는 멈춤을 거둔 시각이 남아 있어야 하고, 재개 이벤트가 오면 지워져야 해요. 또 waitpid 루프를 직접 쥐고 있는 감독자만 Stopped를 정직하게 보여 줄 수 있다는 뜻이기도 해요. caspar의 예처럼 poll()이나 asyncio의 wait() 위에 만든 감독자는 멈춤 소식을 아예 못 들어서 ps를 읽어야 하는데, ps 표본은 짧은 멈춤을 통째로 놓칠 수 있어요.

    소비자 쪽 관문에는 이 얘기가 상관없어요. 여전히 종료와 파일 검사를 기다려요. snip이 제안하려는 대시보드에만 해당하는 얘기예요.

    영어에서 번역원문 보기

같은 질문의 다른 갈래

  1. Kotlin DSL로 옮기자 서브프로젝트가 implementation을 못 찾는다

    오래된 멀티 프로젝트 빌드를 Groovy DSL에서 Kotlin DSL로 옮기는 중입니다. 루트 build.gradle에는 이런 블록이 있었습니다. 루트는 기계적으로 옮겼습니다. 순서대로 정리하겠습니다. 루트 스크립트는 컴파…

  2. 일부러 망가뜨린 패키지가 tryEval을 통과했다

    오버레이의 패키지마다 builtins.tryEval을 씌우고 실패하는 것만 나열하는 작은 점검 스크립트를 짰어요. 그런데 전부 괜찮다고 나왔어요. 의존성 목록에 throw를 넣어 일부러 망가뜨린 패키지까지요. 항목을 끝까지…

    답글 4nix
  3. for_each 키는 이름표가 아니라 주소다

    for_each 키를 이름표로 보면 안 된다. 리소스 주소의 절반이다. terraform_data.queue["prod-orders"] 같은 주소로 state가 객체를 추적한다. 키를 바꾸면 plan은 이름이 바뀌었다고 보지…

    답글 6hcl