기술 문서

Delphi의 HotXLS 파일 모니터 콜백 데드락

HotXLS는 TXLSFileMonitor로 디스크의 워크북을 감시하고, 다른 프로세스가 파일 위에 저장하면 OnFileChanged를 발생시킵니다. 그 핸들러 안에서 Monitor.Active := False를 설정하는 것은 예전에 영원히 멈추곤 했습니다. 워커 스레드가 방금 디스패치한 콜백을 여전히 기다리며 블록되어 있었기 때문입니다. 수정은 스레드 객체가 현재 실행 중인 콜백의 정체를 지니게 하는 것입니다

자기 핸들러 안에서 파일 모니터를 멈추면 왜 멈출까요?

비활성화 호출과 콜백이 서로 다른 두 스레드에서 실행되고, 각각 상대가 끝나길 기다리기 때문입니다. TXLSFileMonitor는 SynchronizeEvent가 True로 설정된 채 출하됩니다. 이는 폴링 스레드가 여러분의 핸들러를 직접 부르지 않는다는 뜻입니다. TThread.Synchronize를 호출해 메서드를 메인 스레드에 넘기고, 메인 스레드가 그것을 실행하고 완료를 알릴 때까지 블록합니다. 그리드를 다시 로드하거나 폼을 다시 그리는 핸들러에는 정확히 원하는 동작이며, 동시에 뻔한 비활성화 호출을 함정으로 만드는 것이기도 합니다

HotXLS 파일 모니터의 데드락 두 얼굴: SynchronizeEvent True에서는 메인 스레드가 Worker.WaitFor에서 블록되는 동안 워커가 TThread.Synchronize에서 블록되고, False에서는 핸들러가 폴링 스레드에서 실행되어 SetActive False가 스레드 자신을 기다리며 OnFileChanged가 반환하지 못합니다
SynchronizeEvent가 켜져 있으면 메인 스레드가 Worker.WaitFor에서 기다리는 동안 워커는 Synchronize에서 기다리고, 꺼져 있으면 핸들러가 폴링 스레드에서 실행되며 자기 종료를 기다립니다. 닫힌 순환 하나, 디스패치 모드 둘입니다
procedure TMainForm.HandleFileChanged(Sender: TObject);
begin
  // TThread.Synchronize 덕분에 여기서는 메인 스레드입니다
  FBook.LoadFromFile(FMonitor.FileName);
  RefreshGrid;
  // 사용자는 다시 로드 한 번만 원했으므로 감시를 멈춥니다
  FMonitor.Active := False;   // 예전에는 결코 반환하지 않았습니다
end;

두 스택을 따라가면 순환은 명확합니다. 메인 스레드는 SetActive(False) 안에 있으며, 이는 워커를 종료시킨 뒤 객체를 해제하기 전에 스레드가 정말 나갔는지 확인하러 Worker.WaitFor를 호출합니다. 그동안 워커 스레드는 TThread.Synchronize 안에 주차되어, SetActive를 부르는 바로 그 핸들러에서 메인 스레드가 돌아오길 기다립니다. 어느 쪽도 움직일 수 없습니다. 애플리케이션은 크래시하지 않고, 예외를 던지지 않으며, 그저 다시 그리기를 멈춥니다. 버그가 가질 수 있는 최악의 진단 시그니처입니다

같은 데드락의 두 번째 얼굴

동기화를 끄는 것은 문제를 피하지 않고 옮길 뿐입니다. SynchronizeEvent를 False로 설정하면 — 메시지 루프가 없는 서비스나 콘솔 도구에서 합리적인 선택 — TXLSFileMonitor는 핸들러를 폴링 스레드에서 직접 호출합니다. 여러분의 핸들러는 이제 워커에서 실행되므로, 그 안의 Monitor.Active := False 호출은 워커 스레드 자신 위에서 Worker.WaitFor에 닿습니다. 스레드는 자기 종료를 기다릴 수 없으므로, 외부의 무엇인가가 프로세스를 죽이기 전까지 블록합니다. 두 변형은 같은 버그입니다. SetActive(False)는 언제나 콜백 밖 어딘가에서 호출된다고 가정했습니다. 그 가정은 닫기 버튼이나 폼 소멸자에서는 성립하지만, 사람들이 제일 먼저 손대는 바로 그 호출 지점에서는 실패합니다. 그래서 그렇게 오래 살아남았습니다. 스레드를 소유하면서 그 스레드에서 사용자 코드를 디스패치하는 어떤 컴포넌트든 같은 위험을 물려받으며, 해법도 모든 경우에 같습니다. 분해 경로는 자신이, 곧 조인하려는 스레드가 여전히 붙들고 있는 콜백 안에서 실행 중인지 구별할 수 있어야 합니다

워커 스레드가 콜백 정체를 지니게 하기

HotXLS는 스레드 객체 자체에, 현재 핸들러를 실행 중인 OS 스레드가 무엇인지를 기록해 해결합니다. TXLSFileMonitorThread.FireEvent은 핸들러를 호출하기 직전 FCallbackThreadID에 GetCurrentThreadId를 찍고, 이후 finally 블록에서 지웁니다. 그 필드 하나로 비활성화 경로는 "무관한 코드에서 호출됨"과 "내 콜백 안에서 호출됨"을 구별할 수 있으며, 두 디스패치 모드에서 똑같이 작동합니다. 동기화 케이스에서는 FireEvent 안의 GetCurrentThreadId가 이미 메인 스레드를 보고하기 때문입니다

procedure TXLSFileMonitorThread.FireEvent;
var
  Owner: TXLSFileMonitor;
  Handler: TNotifyEvent;
begin
  // 대기 중이던 콜백은 비활성화 중 WaitFor에 의해 디스패치될 수 있습니다
  if Terminated then
    Exit;
  Owner := FOwner;
  Handler := Owner.FOnFileChanged;
  if not Assigned(Handler) then
    Exit;
  FCallbackThreadID := GetCurrentThreadId;
  try
    Handler(Owner);
  finally
    // 핸들러가 Owner를 파괴했을 수 있지만 이 스레드는 살아 있습니다
    FCallbackThreadID := 0;
  end;
end;

그러면 SetActive의 비활성화 분기는 의도된 순서로 세 가지를 합니다. 먼저 FThread를 지워 스레드를 모니터에서 분리합니다. 그래서 그 순간부터 GetActive가 False를 보고하고, 같은 핸들러 안의 재진입 Active := False는 스레드를 두 번 분해하려 하지 않고 즉시 나갑니다. 그다음 Terminate를 호출합니다. 그 후에야 스레드 객체를 어떻게 회수할지 결정합니다

HotXLS가 파일 모니터 분해를 해결하는 방법: TXLSFileMonitorThread.FireEvent이 핸들러 호출 전 FCallbackThreadID에 GetCurrentThreadId를 찍고 finally 블록에서 지우므로, SetActive가 FThread를 분리하고 Terminate를 호출한 뒤 콜백 안의 FreeOnTerminate 자가 해제와 클래식 WaitFor 더하기 Free 중에서 고를 수 있습니다
FireEvent은 핸들러 호출 전에 FCallbackThreadID를 찍고 finally 블록에서 지웁니다. SetActive는 정체가 일치하면 FreeOnTerminate를, 호출자가 무관하면 클래식 WaitFor 더하기 Free를 고릅니다
Worker := TXLSFileMonitorThread(FThread);
FThread := nil;            // 이 시점부터 모니터는 비활성
Worker.Terminate;
FHasBaseline := False;
if Worker.FCallbackThreadID = GetCurrentThreadId then
  // 직접이든 동기화든 핸들러는 워커가 나가기 전에 반환해야 하며,
  // 콜백이 풀린 뒤 스스로 해제됩니다
  Worker.FreeOnTerminate := True
else
begin
  Worker.WaitFor;
  Worker.Free;
end;

정체가 일치하면 HotXLS는 결코 기다리지 않습니다. FreeOnTerminate := True를 설정하고 반환하여, 핸들러가 끝나고 Synchronize 호출이 완료되며 Execute가 Terminated를 보고, 콜백 스택이 완전히 풀린 뒤 RTL이 스레드 객체를 해제하게 합니다. 정체가 일치하지 않으면 — 닫기 버튼, 소멸자, 백그라운드 잡 — 클래식 WaitFor 더하기 Free가 여전히 맞습니다. Active := False가 반환된 뒤에는 어떤 추가 콜백도 발화할 수 없다는 확실한 보증을 주기 때문입니다. 종료 경로 둘, 그 사이를 고르는 필드 하나. 짚고 갈 결과가 하나 있습니다. 자가 해제 경로에서는 워커가 핸들러보다 오래 삽니다. FireEvent의 반환 경로에서 무엇도 모니터 객체를 다시 건드려서는 안 됩니다. 핸들러가 그것을 해제했을 권리가 있기 때문입니다. 그래서 FireEvent은 Owner와 Handler를 앞에서 로컬로 캐시하고 finally 절에서는 자기 필드만 건드립니다. 핸들러 호출 뒤의 떠돌이 FOwner.Something 하나는 고쳐진 데드락을 use-after-free로 바꾸며, 이는 엄밀히 더 나쁜 결과입니다

FireEvent은 왜 무엇보다 먼저 Terminated를 확인해야 할까요?

WaitFor가 수동적 대기가 아니기 때문입니다. 메인 스레드에서 TThread.WaitFor의 RTL 구현은 블록되는 동안 동기화 큐를 계속 비웁니다. Synchronize에 주차된 스레드가 종료와 데드락을 일으키는 대신 완료하고 나갈 수 있도록 말입니다. 그것은 기능이며, 닫기 버튼에서 Active := False를 호출하기 몇 마이크로초 전에 대기 중이던 콜백이, 모니터가 이미 FThread 참조를 내려놓은 뒤에도 분해 중에 디스패치될 수 있다는 뜻입니다. FireEvent 맨 위의 가드가 그 디스패치를 no-op으로 만듭니다. 그것이 없으면, 저장이 감시 중인 파일에 떨어지는 동안 메인 폼을 닫는 애플리케이션이 반쯤 폐기된 상태에 대해 다시 로드 핸들러를 실행할 수 있고, 결과적인 access violation은 종료 순서 문제처럼 보이지 않습니다. 여러분의 컴포넌트가 Synchronize로 작업을 디스패치한다면 규칙은 일반화됩니다. 모든 동기화 메서드의 첫 문장은 Terminated 확인이어야 합니다. 스레드가 사라졌을 수 있어서가 아니라, 큐가 멈추기로 한 결정보다 오래 살기 때문입니다

CSV 가져오기 경로의 같은 모양의 버그

모니터 수정을 낳은 감사에서 텍스트 임포터의 형제 결함이 드러났으며, 나란히 읽을 가치가 있습니다. 밑바닥 실수가 동일하기 때문입니다. 구성 조각이 스크래치 상태인 것처럼 소비된 것입니다. TXLSCSVLoader는 내보낸 리포트에서 진짜 헤더 위에 앉는 배너 줄용 SkipRows 속성을 노출합니다. 파서는 그 줄들을 처리하는 동안 카운트다운이 필요하며, 원래 구현은 속성 백킹 필드 자체에서 카운트다운했습니다. 파일 하나를 가져오면 SkipRows가 조용히 0이 되었고, 같은 로더를 두 번째 파일에 재사용하면 배너 줄이 그리드에 들어갔습니다

Loader := TXLSCSVLoader.Create;
try
  Loader.SkipRows := 2;             // 헤더 위의 배너 줄 두 개
  Loader.ParseFile('jan-export.csv');
  ReadGrid(Loader);
  // 여기서 SkipRows는 여전히 2입니다: FSkipRemaining은 파싱마다 리셋됨
  Loader.ParseFile('feb-export.csv');
  ReadGrid(Loader);
finally
  Loader.Free;
end;

HotXLS는 이제 FSkipRows를 선언된 설정으로 유지하고, 구분자 경로와 고정 폭 경로 양쪽 모두에서 각 파싱 시작 시 별도의 FSkipRemaining 카운터를 리셋합니다. 일반 규칙은 적용이 싸고 제값을 합니다. 루프가 그것을 줄인다면 그것은 속성 백킹 필드가 아닙니다. 같은 배치에서 XlsCsvTryParseISODateTime도 조였습니다. 그것은 파싱 가능한 프리픽스를 온전한 날짜로 받아들이고 있었습니다. 이제 전체 필드, 즉 길이, 구분자 위치, 모든 자릿수를 검증하므로, 단지 날짜처럼 생긴 것으로 시작하는 주문 참조 같은 값은 잘못된 TDateTime이 되는 대신 텍스트로 남습니다. 가져오기에서의 조용한 타입 강제 변환은 거부된 필드보다 하류 디버깅이 훨씬 비쌉니다. 이 주제는 HotXLS가 워크북을 동시 읽기·쓰기에서 지키는 방법에도 흐릅니다

HotXLS 감사 뒤의 CSV 로더 결함: 속성 백킹 필드에서 SkipRows를 카운트다운하면 한 번의 파싱 후 조용히 0이 되어 두 번째 파일의 배너 줄이 그리드로 샜지만, 수정된 모델은 FSkipRows를 선언된 설정으로 유지하고 모든 ParseFile 호출 시작 시 별도의 FSkipRemaining 카운터를 리셋합니다
속성 백킹 필드에서 카운트다운하는 것은 구성을 스크래치 상태로 만듭니다. 첫 파싱이 SkipRows를 0으로 만들지만, 수정된 모델은 FSkipRows를 선언된 상태로 유지하고 매 파싱 시작 시 FSkipRemaining을 리셋합니다

이 수정이 주는 것과 주지 않는 것

보증에 대해 정확해지세요. 스레드 소유권 변경은 이전에 치명적이던 패턴 정확히 하나를 안전하게 만듭니다. 두 디스패치 모드 중 어느 것에서든 자기 OnFileChanged 핸들러 안에서 TXLSFileMonitor를 비활성화하거나 해제하는 것입니다. 일반적인 동시성 설계가 아니며, 컴포넌트를 임의의 스레드에서 몰아도 안전하게 만들지 않습니다

  • 속성 쓰기는 여전히 스레드 안전하지 않습니다. FileName, IntervalMs, SynchronizeEvent는 한 스레드, 보통 모니터를 만든 스레드에서 설정하세요
  • IntervalMs는 다음 활성화부터 적용되며, 워커는 짧은 조각으로 잠들어 긴 폴링 주기에서도 Terminate가 신속히 존중됩니다
  • 감지는 마지막 쓰기 시간과 크기로 이루어지므로 둘 다 보존하는 변경은 보이지 않으며, 저장 중간에 사라진 파일은 발화하지 않고 새 콘텐츠가 자리한 뒤 이벤트가 도착합니다
  • SynchronizeEvent가 False로 설정되면 여러분의 핸들러는 워커 스레드에서 실행되며, 백그라운드 스레드에서 VCL 컨트롤을 건드리는 것에 대한 모든 규칙이 그 안에서 하는 일에 적용됩니다
  • 핸들러에서 워크북을 다시 로드하는 것은 여전히 자기 규율이 필요하며, 특히 다른 코드가 교체되는 북에 참조를 붙들고 있다면 더욱 그렇습니다

실무에서 가장 신경 쓸 부분은 모니터가 아니라 다시 로드 핸들러입니다. 변경 이벤트는 대개 쓰기 프로세스가 아직 정착하는 동안 도착하므로, 파일을 즉시 여는 로드는 부분 커밋된 저장을 만날 수 있습니다. 짧은 지연 후 한 번 재시도하는 것이 첫 스탬프를 믿는 것보다 견고합니다. 여러분의 코드가 감시 중인 파일을 쓰는 쪽이라면, HotXLS가 모든 저장을 크래시 안전하게 만드는 방법에서 설명한 단계적 임시 파일과 원자적 rename도 모니터에게 중간 크기들의 흐름이 아니라 깨끗한 단일 전이 이벤트를 관찰할 거리를 줍니다. 여러 워처가 같은 다시 로드 루틴에 먹이를 준다면, 동시 ZIP 인플레이션 주변의 읽기 게이트의 직렬화 우려가 다음에 읽을 것입니다. 더 넓은 교훈은 특정 컴포넌트를 넘어 살아남습니다. 스레드를 소유하고 사용자 코드로 콜백하는 어떤 클래스든 자기 콜백 프레임을 인식할 수 있는 분해 경로가 필요합니다. 핸들러는 사용자가 멈추라고 말하기에 세상에서 가장 자연스러운 자리이기 때문입니다. 콜백 스레드 id를 기록하는 것은 DWORD 하나가 들며, 설명 불가능한 멈춤을 평범한 객체 수명 질문으로 바꿉니다. 파일 모니터링, 원자적 저장, 텍스트 가져오기는 모두 Delphi와 C++Builder용 HotXLS Delphi Component에 아무 추가 설정 없이 출하됩니다