Files
kis_bot/kis_trader/backtest/param_search_pool.py
Hwang 61c72a8a4c feat(tests): 신규 키움 웹소켓 조건검색 및 실시간 조건검색 테스트 추가
변경 사항
----
- _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가
- _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가
- _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가

영향
----
- 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임
- 기존 기능에 대한 영향 없음

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 01:27:00 +09:00

712 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
kis_trader/backtest/param_search_pool.py — 파라서치 ProcessPool 공통 안전장치
============================================================================
- PR_SET_PDEATHSIG + PPID 감시: 부모 종료·터미널 끊김 후에도 워커(PPID=1)가 CPU를 잡지 않게 함
- managed_process_pool: Ctrl+C / SIGTERM 시 cancel + terminate + kill 순 정리
- ParamSearchRunLock: 동일 전략 파라서치 중복 실행 방지
- ParamSearchSharedPayload: 캔들·유니버스 1회 파일 → 워커 init 1회 로드 (task마다 pickle 방지)
- param_search_max_workers: CPU 80%(고정) + 가용 RAM × PARAM_SEARCH_MEM_FRAC(기본 80%) 중 min
"""
from __future__ import annotations
import atexit
import os
import pickle
import signal
import sys
import tempfile
import time
from concurrent.futures import (
FIRST_COMPLETED,
Future,
ProcessPoolExecutor,
as_completed,
wait,
)
from contextlib import contextmanager
from itertools import product
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, TypeVar
from kis_trader.utils.env import get_env_float, get_env_int
# 워커 프로세스 전용 — fork/spawn 직후 worker_init·shared init 에서 기록
_EXPECTED_PPID: int = 0
_WORKER_SHARED: Optional[Dict[str, Any]] = None
T = TypeVar("T")
def worker_init() -> None:
"""ProcessPoolExecutor initializer — 워커 생성 직후 한 번 호출."""
global _EXPECTED_PPID
_EXPECTED_PPID = os.getppid()
try:
if sys.platform.startswith("linux"):
import ctypes
PR_SET_PDEATHSIG = 1
libc = ctypes.CDLL("libc.so.6", use_errno=True)
libc.prctl(PR_SET_PDEATHSIG, signal.SIGTERM, 0, 0, 0)
except Exception:
pass
try:
signal.signal(signal.SIGINT, signal.SIG_IGN)
except Exception:
pass
def worker_shared_get() -> Dict[str, Any]:
"""워커: ParamSearchSharedPayload 로 로드된 캔들·유니버스 등."""
return _WORKER_SHARED if isinstance(_WORKER_SHARED, dict) else {}
def _worker_init_from_shared_path(payload_path: str) -> None:
"""워커 1회: 디스크 pickle → _WORKER_SHARED (task 인자로 대용량 dict 전달 금지)."""
global _WORKER_SHARED
worker_init()
try:
with open(payload_path, "rb") as f:
_WORKER_SHARED = pickle.load(f)
except Exception:
_WORKER_SHARED = {}
def linux_mem_available_bytes() -> int:
"""Linux MemAvailable (없으면 MemFree)."""
try:
mem: Dict[str, int] = {}
with open("/proc/meminfo", "r", encoding="utf-8") as f:
for line in f:
parts = line.split()
if len(parts) >= 2 and parts[0].rstrip(":") in (
"MemAvailable", "MemFree", "SwapFree",
):
mem[parts[0].rstrip(":")] = int(parts[1]) * 1024
if "MemAvailable" in mem:
return mem["MemAvailable"]
if "MemFree" in mem:
return mem["MemFree"]
except OSError:
pass
return 2 * 1024 ** 3
def estimate_pickle_bytes(obj: Any) -> int:
"""대용량 payload 예상 크기 (pickle 직렬화 길이)."""
try:
return len(pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL))
except Exception:
return 0
def downsample_combos_uniform(
combos: List[Any],
max_combos: int,
) -> Tuple[List[Any], int]:
"""그리드 조합이 많을 때 균등 간격 샘플링 (tail/momentum fast 공통)."""
cap = int(max_combos)
total = len(combos)
if cap <= 0 or total <= cap:
return combos, 0
if cap == 1:
return [combos[0]], total - 1
last = total - 1
picked: List[Any] = []
for i in range(cap):
idx = int(round(i * last / (cap - 1)))
picked.append(combos[idx])
return picked, max(0, total - len(picked))
def resolve_param_search_max_combos(
mode: str,
*,
strategy_env_prefix: str,
default_fast: int,
default_other: int = 0,
max_combos_override: Optional[int] = None,
) -> int:
"""
fast 모드 기본: 균등 샘플 상한.
우선순위: CLI override → {PREFIX}_FAST_MAX_COMBOS → PARAM_SEARCH_FAST_MAX_COMBOS.
"""
if max_combos_override is not None:
return max(0, int(max_combos_override))
m = (mode or "fast").strip().lower()
if m == "fast":
specific = get_env_int(f"{strategy_env_prefix}_FAST_MAX_COMBOS", 0)
if specific > 0:
return specific
return get_env_int("PARAM_SEARCH_FAST_MAX_COMBOS", default_fast)
cap = get_env_int(f"{strategy_env_prefix}_PARAM_SEARCH_MAX_COMBOS", default_other)
return max(0, cap)
def cap_combos_uniform(
dict_combos: List[Any],
mode: str,
*,
strategy_env_prefix: str,
default_fast: int,
default_other: int = 0,
max_combos_override: Optional[int] = None,
) -> Tuple[List[Any], int, int, int]:
"""그리드 전체 → (균등 샘플 리스트, 그리드 크기, 적용 cap, 제외 개수)."""
total_grid = len(dict_combos)
cap = resolve_param_search_max_combos(
mode,
strategy_env_prefix=strategy_env_prefix,
default_fast=default_fast,
default_other=default_other,
max_combos_override=max_combos_override,
)
dropped = 0
sampled = dict_combos
if cap > 0 and total_grid > cap:
sampled, dropped = downsample_combos_uniform(dict_combos, cap)
return sampled, total_grid, cap, dropped
def grid_total_combinations(axes: List[List[Any]]) -> int:
"""축별 값 개수의 곱 — 데카르트곱을 **펼치지 않고** 전체 조합 수만 계산.
(list(product(...)) 로 수천만 개를 RAM 에 펼치다 OOM 나는 것을 막기 위함)
"""
total = 1
for vals in axes:
n = len(vals)
if n <= 0:
return 0
total *= n
return total
def _decode_combo_by_index(axes: List[List[Any]], idx: int) -> Tuple[Any, ...]:
"""혼합진법 디코딩: 정수 idx → 각 축의 값 (마지막 축이 최하위 자리).
itertools.product 와 동일한 순서의 idx→조합 매핑이라, '전체 리스트를 안 만들고'
원하는 자리(idx)의 조합만 바로 꺼낼 수 있다. 초대형 그리드 균등샘플 핵심.
"""
out: List[Any] = [None] * len(axes)
for ax in range(len(axes) - 1, -1, -1):
n = len(axes[ax])
out[ax] = axes[ax][idx % n]
idx //= n
return tuple(out)
def _uniform_indices(total: int, count: int) -> List[int]:
"""0..total-1 에서 균등 간격 정수 인덱스 count개 (stage1 균등샘플과 동일한 간격)."""
if total <= 0 or count <= 0:
return []
if count == 1:
return [0]
if count >= total:
return list(range(total))
last = total - 1
seen: set = set()
out: List[int] = []
for i in range(count):
idx = int(round(i * last / (count - 1)))
if idx not in seen:
seen.add(idx)
out.append(idx)
return out
def cap_combos_uniform_lazy(
keys: List[str],
axes: List[List[Any]],
mode: str,
*,
strategy_env_prefix: str,
default_fast: int,
default_other: int = 0,
max_combos_override: Optional[int] = None,
valid_fn: Optional[Callable[[Dict[str, Any]], bool]] = None,
max_materialize: Optional[int] = None,
) -> Tuple[List[Dict[str, Any]], int, int, int]:
"""데카르트곱을 통째로 펼치지 않고 (cap 균등샘플 / 전수) dict 조합 생성.
- 전체 곱이 PARAM_SEARCH_MAX_MATERIALIZE(기본 200만) **이하**면 기존 경로 그대로
(materialize → 유효성 필터 → 균등 cap). 기존 동작·결과 100% 동일.
- 그보다 **크면** 인덱스 균등샘플: 균등 간격 인덱스를 혼합진법으로 디코딩해
그 조합만 생성한다. 수천만 개를 펼치지 않으므로 OOM 없음 + 균등 커버리지 유지.
반환 형식은 cap_combos_uniform 과 동일: (dict_combos, total_grid, cap, dropped)
"""
cap = resolve_param_search_max_combos(
mode,
strategy_env_prefix=strategy_env_prefix,
default_fast=default_fast,
default_other=default_other,
max_combos_override=max_combos_override,
)
if max_materialize is None:
max_materialize = get_env_int("PARAM_SEARCH_MAX_MATERIALIZE", 2_000_000)
raw_total = grid_total_combinations(axes)
# ── 작은 그리드: 기존 경로 그대로 (동작·결과 동일) ──
if raw_total <= max(1, max_materialize):
dict_combos = [dict(zip(keys, c)) for c in product(*axes)]
if valid_fn is not None:
dict_combos = [c for c in dict_combos if valid_fn(c)]
total_grid = len(dict_combos)
dropped = 0
if cap > 0 and total_grid > cap:
dict_combos, dropped = downsample_combos_uniform(dict_combos, cap)
return dict_combos, total_grid, cap, dropped
# ── 초대형 그리드: 펼치지 않고 인덱스 균등샘플 ──
target = cap if cap > 0 else max_materialize
# 유효성 필터로 일부 빠질 수 있어 넉넉히 뽑은 뒤(oversample) 통과분을 다시 균등 cap.
oversample_mult = max(1, get_env_int("PARAM_SEARCH_OVERSAMPLE_MULT", 4))
n_idx = min(raw_total, max(target, target * oversample_mult))
picked: List[Dict[str, Any]] = []
for idx in _uniform_indices(raw_total, n_idx):
c = dict(zip(keys, _decode_combo_by_index(axes, idx)))
if valid_fn is None or valid_fn(c):
picked.append(c)
if cap > 0 and len(picked) > cap:
picked, _ = downsample_combos_uniform(picked, cap)
dropped = max(0, raw_total - len(picked))
return picked, raw_total, cap, dropped
def param_search_workers_by_memory(payload_bytes: int) -> int:
"""
가용 RAM × PARAM_SEARCH_MEM_FRAC(기본 0.8) 안에서 워커 수 추정.
부모 1×payload + 워커당 ~(payload + WORKER_EXTRA) 보수적 가정.
"""
avail = linux_mem_available_bytes()
frac = get_env_float("PARAM_SEARCH_MEM_FRAC", 0.8)
budget = max(0, int(avail * frac))
parent_extra = get_env_int("PARAM_SEARCH_PARENT_EXTRA_MB", 512) * 1024 ** 2
worker_extra = get_env_int("PARAM_SEARCH_WORKER_EXTRA_MB", 384) * 1024 ** 2
fallback = get_env_int("PARAM_SEARCH_MB_PER_WORKER", 768) * 1024 ** 2
parent_need = (max(0, payload_bytes) + parent_extra) if payload_bytes > 0 else parent_extra + fallback
per_worker = (max(0, payload_bytes) + worker_extra) if payload_bytes > 0 else fallback
remain = budget - parent_need
if remain <= 0:
return 1
return max(1, remain // max(per_worker, 1))
def param_search_max_workers(payload_bytes: int = 0) -> int:
"""CPU 코어 × PARAM_SEARCH_CPU_FRAC(기본 0.8=80% 고정) vs 메모리 예산 — min."""
n_cpu = os.cpu_count() or 4
cpu_frac = get_env_float("PARAM_SEARCH_CPU_FRAC", 0.8)
cpu_workers = max(1, int(n_cpu * cpu_frac))
cap = get_env_int("PARAM_SEARCH_MAX_WORKERS", 0)
if cap > 0:
cpu_workers = min(cpu_workers, cap)
mem_workers = param_search_workers_by_memory(payload_bytes)
return max(1, min(cpu_workers, mem_workers))
def param_search_worker_budget_line(payload_bytes: int) -> str:
"""run_search 로그용 — 가용 메모리·워커 산출 근거."""
avail_gb = linux_mem_available_bytes() / (1024 ** 3)
frac = get_env_float("PARAM_SEARCH_MEM_FRAC", 0.8)
cpu_w = max(1, int((os.cpu_count() or 4) * get_env_float("PARAM_SEARCH_CPU_FRAC", 0.8)))
mem_w = param_search_workers_by_memory(payload_bytes)
workers = param_search_max_workers(payload_bytes)
payload_mb = payload_bytes / (1024 ** 2) if payload_bytes > 0 else 0.0
return (
f"💾 메모리: 가용 {avail_gb:.1f}GB × {frac * 100:.0f}% 예산 | "
f"payload ~{payload_mb:.0f}MB | CPU워커≤{cpu_w} RAM워커≤{mem_w} → 실제 {workers}"
)
def param_search_chunk_size(total: int, max_workers: int) -> int:
"""
청크 크기 — 조합 수가 적어도 워커 수 이상으로 나눠 CPU 포화.
(fast 240조합 × 청크 50 → 5청크만 → 5코어만 100% 사용되는 문제 방지)
"""
if total <= 0:
return 50
chunk_max = get_env_int("PARAM_SEARCH_CHUNK_MAX", 300)
mult = max(1, get_env_int("PARAM_SEARCH_CHUNK_MULTIPLIER", 4))
target_chunks = min(total, max(max_workers, max_workers * mult))
return max(1, min(chunk_max, -(-total // target_chunks)))
def param_search_chunk_plan(
total: int,
payload_bytes: int = 0,
) -> Tuple[int, int, int]:
"""(max_workers, chunk_size, num_chunks) — run_search 공통."""
workers = param_search_max_workers(payload_bytes)
size = param_search_chunk_size(total, workers)
n_chunks = max(1, -(-total // size))
return workers, size, n_chunks
class ParamSearchProgressETA:
"""멀티프로세스 파도식 청크 완료에 맞춘 ETA 추정기.
워커 N개가 동시에 청크를 처리하면 완료가 파도(배치) 단위로 몰린다.
청크 간격 기반 ETA는 0↔파도통째로 요동하므로, 워밍업 1파도 제외 누적 평균 사용.
"""
def __init__(self, total_chunks: int, max_workers: int) -> None:
self._total_chunks = max(1, total_chunks)
self._warmup_chunks = min(max(1, max_workers), self._total_chunks)
self._warmup_end_elapsed: Optional[float] = None
def remaining_sec(self, processed: int, elapsed_so_far: float) -> float:
"""남은 예상 초 — 워밍업 파도 이후 누적 평균 × 남은 청크."""
processed = max(1, processed)
remaining_chunks = self._total_chunks - processed
if remaining_chunks <= 0:
return 0.0
if processed == self._warmup_chunks:
self._warmup_end_elapsed = elapsed_so_far
if self._warmup_end_elapsed is not None and processed > self._warmup_chunks:
steady_elapsed = elapsed_so_far - self._warmup_end_elapsed
steady_done = processed - self._warmup_chunks
per_chunk = steady_elapsed / max(1, steady_done)
else:
per_chunk = elapsed_so_far / processed
return per_chunk * remaining_chunks
@staticmethod
def format_sec(sec: float) -> str:
"""초 → 'N분 N초' / 'N시간 N분 N초'."""
eta_m, eta_s = divmod(max(0, int(sec)), 60)
eta_h, eta_m = divmod(eta_m, 60)
if eta_h > 0:
return f"{eta_h}시간 {eta_m}{eta_s}"
if eta_m > 0:
return f"{eta_m}{eta_s}"
return f"{eta_s}"
@staticmethod
def format_elapsed(elapsed_so_far: float) -> str:
"""경과 초 → 'N분 N초'."""
elapsed_m, elapsed_s = divmod(max(0, int(elapsed_so_far)), 60)
return f"{elapsed_m}{elapsed_s}"
@staticmethod
def render_bar(frac: float, width: int = 20) -> str:
"""진행 바: 채움 ■ / 빈칸 □ (시각적 진행 표시용)."""
frac = max(0.0, min(1.0, frac))
filled = int(round(frac * width))
return "" * filled + "" * (width - filled)
def param_search_max_inflight(max_workers: int) -> int:
"""동시 pending Future 상한 (큐에 대용량 인자 적재 방지)."""
extra = get_env_int("PARAM_SEARCH_INFLIGHT_EXTRA", 1)
cap = get_env_int("PARAM_SEARCH_MAX_INFLIGHT", 0)
if cap > 0:
return max(1, cap)
return max(1, max_workers + max(0, extra))
class ParamSearchSharedPayload:
"""
부모: 캔들·유니버스 등 1회 pickle 파일.
워커: init 시 1회 로드 → evaluate 에서 worker_shared_get() 사용.
"""
def __init__(self, data: Dict[str, Any]) -> None:
self.data = dict(data)
self.path: str = ""
self._prepared = False
def estimate_bytes(self) -> int:
return estimate_pickle_bytes(self.data)
def prepare(self) -> str:
if self._prepared and self.path and os.path.isfile(self.path):
return self.path
fd, path = tempfile.mkstemp(prefix="kis_param_search_", suffix=".pkl")
os.close(fd)
with open(path, "wb") as f:
pickle.dump(self.data, f, protocol=pickle.HIGHEST_PROTOCOL)
self.path = path
self._prepared = True
return self.path
def cleanup(self) -> None:
if self.path and os.path.isfile(self.path):
try:
os.remove(self.path)
except OSError:
pass
self.path = ""
self._prepared = False
def make_process_pool(
max_workers: int,
*,
shared_payload_path: Optional[str] = None,
) -> ProcessPoolExecutor:
"""ProcessPoolExecutor 생성 (max_tasks_per_child 로 장시간 워커 누수 완화).
shared_payload_path: spawn 시 pickle 가능한 모듈 함수 + initargs 로 전달 (nested closure 금지).
"""
max_tasks = get_env_int("PARAM_SEARCH_MAX_TASKS_PER_CHILD", 50)
if shared_payload_path:
init_fn = _worker_init_from_shared_path
init_args: Tuple[Any, ...] = (shared_payload_path,)
else:
init_fn = worker_init
init_args = ()
kwargs: dict = {
"max_workers": max_workers,
"initializer": init_fn,
"initargs": init_args,
}
if max_tasks > 0:
kwargs["max_tasks_per_child"] = max_tasks
return ProcessPoolExecutor(**kwargs)
def shutdown_executor_hard(
executor: Optional[ProcessPoolExecutor],
*,
wait_sec: Optional[float] = None,
) -> None:
"""Executor 워커: cancel → shutdown(wait=False) → SIGTERM → SIGKILL."""
if executor is None:
return
if wait_sec is None:
wait_sec = get_env_float("PARAM_SEARCH_SHUTDOWN_WAIT_SEC", 8.0)
try:
executor.shutdown(wait=False, cancel_futures=True)
except TypeError:
executor.shutdown(wait=False)
except Exception:
pass
procs = getattr(executor, "_processes", None)
if not procs:
return
deadline = time.time() + wait_sec
for proc in list(procs.values()):
try:
if proc.is_alive():
proc.terminate()
except Exception:
pass
while time.time() < deadline:
if not any(p.is_alive() for p in procs.values()):
break
time.sleep(0.2)
for proc in procs.values():
try:
if proc.is_alive():
proc.kill()
except Exception:
pass
@contextmanager
def managed_process_pool(
max_workers: int,
*,
shared_payload: Optional[ParamSearchSharedPayload] = None,
) -> Iterator[ProcessPoolExecutor]:
"""정상 종료는 wait=True, KeyboardInterrupt 등은 hard shutdown."""
payload_path = shared_payload.prepare() if shared_payload else None
executor = make_process_pool(max_workers, shared_payload_path=payload_path)
try:
yield executor
except (KeyboardInterrupt, SystemExit):
shutdown_executor_hard(executor)
raise
except BaseException:
shutdown_executor_hard(executor)
raise
else:
try:
executor.shutdown(wait=True, cancel_futures=False)
except TypeError:
executor.shutdown(wait=True)
except Exception:
shutdown_executor_hard(executor)
finally:
if shared_payload is not None:
shared_payload.cleanup()
def iter_pool_chunk_results(
executor: ProcessPoolExecutor,
chunks: List[Any],
submit_fn: Callable[[Any], Future],
*,
max_workers: int = 1,
max_inflight: Optional[int] = None,
heartbeat_sec: Optional[float] = None,
on_heartbeat: Optional[Callable[[int, int, float, int], None]] = None,
) -> Iterator[Any]:
"""
청크별 Future — pending 수를 max_inflight 로 제한 (메모리·큐 폭주 방지).
submit_fn(chunk) → Future (대용량 dict 는 ParamSearchSharedPayload 사용).
완료 청크가 안 나오는 '대기 구간'마다(첫 청크 워밍업 포함) 하트비트를 찍어
화면이 멈춘 것처럼 보이지 않게 한다. (사람이 진행을 눈으로 확인하고 안심하도록)
- on_heartbeat(done_count, total, elapsed_sec, running) 콜백을 주면 그걸 호출
(호출자가 진행 바·경과·마지막 ETA 를 같은 줄에 다시 그림 — 권장).
- 콜백이 없으면 내장 하트비트(진행 바 + 완료 수 + 경과)를 직접 출력.
터미널이면 ~1초마다, 파일(nohup)이면 heartbeat_sec(기본 10초)마다 갱신.
"""
if not chunks:
return
inflight = max_inflight if max_inflight is not None else param_search_max_inflight(max_workers)
if heartbeat_sec is None:
heartbeat_sec = get_env_float("PARAM_SEARCH_HEARTBEAT_SEC", 10.0)
use_cr = sys.stdout.isatty()
# 터미널은 자주(1초) 갱신해 진행 바가 살아있게, 파일은 너무 잦은 줄 방지로 길게.
beat_iv = 1.0 if use_cr else max(1.0, heartbeat_sec)
total = len(chunks)
pending: Dict[Future, Any] = {}
chunk_iter = iter(chunks)
def _fill() -> None:
while len(pending) < inflight:
try:
ch = next(chunk_iter)
except StopIteration:
break
pending[submit_fn(ch)] = ch
_fill()
start = time.time()
done_count = 0
while pending:
# 완료된 청크가 생길 때까지 beat_iv 단위로 끊어 대기 → 그 사이 하트비트 출력.
done, _ = wait(list(pending.keys()), timeout=beat_iv,
return_when=FIRST_COMPLETED)
if not done:
if heartbeat_sec <= 0:
continue
el = time.time() - start
running = len(pending)
if on_heartbeat is not None:
on_heartbeat(done_count, total, el, running)
else:
bar = ParamSearchProgressETA.render_bar(done_count / max(1, total))
if done_count == 0:
label = f"🔥 워밍업·첫 청크 진행 중(워커 {running})"
else:
label = f"⚙️ 처리 중(진행 {running})"
msg = (f"{label} {bar} {done_count}/{total} | "
f"경과 {ParamSearchProgressETA.format_elapsed(el)}")
if use_cr:
sys.stdout.write(f"\r{msg} ")
sys.stdout.flush()
else:
print(msg, flush=True)
continue
for fut in done:
pending.pop(fut, None)
done_count += 1
yield fut.result()
_fill()
def _is_pid_alive(pid: int) -> bool:
if pid <= 0:
return False
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
else:
return True
class ParamSearchRunLock:
"""동일 이름 파라서치 중복 실행 방지 (PID 락 파일)."""
def __init__(self, name: str) -> None:
self.name = name
lock_dir = os.path.join(os.path.expanduser("~"), ".kis_bot_locks")
os.makedirs(lock_dir, exist_ok=True)
self.path = os.path.join(lock_dir, f"{name}.lock")
self._acquired = False
def acquire(self) -> bool:
if os.path.isfile(self.path):
old_pid = 0
try:
with open(self.path, "r", encoding="utf-8") as f:
old_pid = int(f.read().strip().split()[0])
except (ValueError, OSError):
old_pid = 0
if _is_pid_alive(old_pid):
return False
try:
os.remove(self.path)
except OSError:
pass
try:
fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(f"{os.getpid()}\n")
self._acquired = True
atexit.register(self.release)
return True
except FileExistsError:
return False
def release(self) -> None:
if not self._acquired:
return
try:
if os.path.isfile(self.path):
with open(self.path, "r", encoding="utf-8") as f:
content = f.read().strip()
if content.startswith(str(os.getpid())):
os.remove(self.path)
except OSError:
pass
self._acquired = False
def __enter__(self) -> "ParamSearchRunLock":
if not self.acquire():
raise RuntimeError(
f"이미 실행 중인 {self.name} 파라서치가 있습니다.\n"
f" ps -ef | grep {self.name}\n"
f" pkill -f '{self.name}' 또는 rm {self.path}"
)
return self
def __exit__(self, *_args) -> None:
self.release()
def try_acquire_run_lock(name: str) -> Optional[ParamSearchRunLock]:
"""락 획득 실패 시 None (main 에서 메시지 출력용)."""
lock = ParamSearchRunLock(name)
if lock.acquire():
return lock
return None
def assert_parent_alive() -> None:
"""
워커 청크 루프에서 호출.
부모가 죽어 init(PPID=1)에 붙었거나 예상 부모와 다르면 즉시 종료.
"""
ppid = os.getppid()
if ppid == 1:
os._exit(0)
if _EXPECTED_PPID > 1 and ppid != _EXPECTED_PPID:
os._exit(0)