สคริปต์ Python เปิด terminal ซ้อนอีกตัว เพราะ initialize(path=) ไม่ใช่ attach
ฟังก์ชันที่ชื่อว่า initialize ทำสองอย่างในคำสั่งเดียว คือต่อเข้าโปรแกรมที่รันอยู่ หรือสตาร์ตโปรแกรมนั้นขึ้นมาเองถ้ายังไม่มี สคริปต์ที่ตั้งใจจะเป็นแค่ผู้อ่านข้อมูลเลยกลายเป็นผู้ควบคุมวงจรชีวิตของ terminal โดยที่คนเขียนไม่ได้ตั้งใจ
สรุปใน 1 นาที
- อาการ/ปัญหา
- สคริปต์ที่เรียก initialize(path=) ของ MT5 สตาร์ต terminal ใหม่ซ้อนแล้วแย่งโฟลเดอร์ข้อมูลกับบัญชี
- ใครควรอ่าน
- คนเขียนสคริปต์ Python ที่ตั้งใจเป็นแค่ผู้อ่านข้อมูลจาก MT5
- ทางแก้แบบสั้น
- เช็ก process ก่อนเรียก initialize ปักบัญชีตอนเริ่มแล้วเทียบทุกรอบ และรับมือ session ที่ตายเงียบหลัง terminal restart
พอดีผมมีสคริปต์ Python ตัวหนึ่งทำหน้าที่เก็บข้อมูลจาก terminal ของ MetaTrader 5 ลงคลังข้อมูลทุกห้านาที เอาไว้ใช้วิเคราะห์ย้อนหลัง บนเครื่องเดียวกันมีโปรแกรมอีกตัวที่ใช้ terminal ตัวเดียวกันนั้นอยู่ด้วย วันหนึ่งผมพบว่ามีหน้าต่าง terminal โผล่ขึ้นมาเองทั้งที่ไม่มีใครเปิด และมันไปทับซ้อนกับตัวที่ตัวเรียกโปรแกรมตอน login กำลังจะเปิด ได้ terminal สองชุดที่ใช้โฟลเดอร์ข้อมูลเดียวกันและบัญชีเดียวกัน ตัวการคือสคริปต์เก็บข้อมูลของผมเอง
ผมเข้าใจมาตลอดว่าพารามิเตอร์ path ของ initialize() คือการบอกว่า "ให้ไปต่อกับ terminal ตัวที่อยู่ตรงนี้" ซึ่งถูกแค่ครึ่งเดียว อีกครึ่งคือถ้าตรงนั้นยังไม่มี process รันอยู่ ไลบรารีจะสตาร์ตให้เอง ชื่อโบรกเกอร์ ชื่อเซิร์ฟเวอร์ เลขบัญชี และพาธในบทความนี้ผมเปลี่ยนเป็นของสมมุติทั้งหมด ระบบนี้เป็นระบบเก็บข้อมูลเพื่อวิเคราะห์ย้อนหลัง
อาการ: terminal โผล่มาเอง แล้วแย่งของกับตัวจริง
โค้ดเดิมของผมหน้าตาแบบนี้ ซึ่งเป็นแบบเดียวกับตัวอย่างที่หาได้ทั่วไป
import MetaTrader5 as mt5
TERMINAL = r"C:\terminals\broker-a\terminal64.exe"
# ตั้งใจว่า "ต่อเข้า terminal ตัวนี้" แต่ถ้ามันยังไม่รัน บรรทัดนี้จะเปิดให้เอง
if not mt5.initialize(path=TERMINAL, portable=True):
raise SystemExit(f"init failed: {mt5.last_error()}")
ตราบใดที่ terminal เปิดอยู่ก่อนแล้ว บรรทัดนี้ทำงานแบบที่ผมเข้าใจ คือต่อเข้าไปเฉย ๆ ปัญหาเกิดในสามจังหวะที่ผมไม่ได้คิดถึง จังหวะแรกคือตอนเครื่องเพิ่งบูต สคริปต์ถูกเรียกก่อนที่ตัวเรียกโปรแกรมจะเปิด terminal จังหวะที่สองคือตอนผมปิด terminal เองเพื่อทำอย่างอื่น แล้วรอบถัดไปของสคริปต์เปิดมันกลับขึ้นมา จังหวะที่สามคือตอน terminal ปิดตัวเพื่ออัปเดต
ทั้งสามจังหวะได้ผลเหมือนกัน คือมี terminal ที่สคริปต์เก็บข้อมูลเป็นคนเปิด ไม่ใช่คนหรือโปรแกรมที่ควรเป็นเจ้าของ และถ้าเจ้าของตัวจริงเปิดอีกตัวตามมา ก็ได้สองชุดที่ชี้ไปโฟลเดอร์ข้อมูลแบบ portable เดียวกันและล็อกอินบัญชีเดียวกัน
ปัญหาไม่ได้อยู่ที่ไลบรารีทำผิด พฤติกรรมนี้สะดวกมากสำหรับสคริปต์ที่รันเดี่ยว ปัญหาอยู่ที่ระบบของผมมีผู้ใช้ terminal มากกว่าหนึ่งราย พอผู้อ่านข้อมูลตัวหนึ่งมีสิทธิ์สตาร์ต terminal ได้ มันก็มีสิทธิ์สตาร์ตผิดเวลา ผิดลำดับ และซ้อนกับตัวที่คนอื่นกำลังใช้งานอยู่ได้ด้วย กติกาที่ผมตั้งหลังจากนั้นคือ ทั้งเครื่องมีตัวเดียวที่มีสิทธิ์เปิด terminal คือตัวเรียกโปรแกรมตอน login และเปิดได้เฉพาะเมื่อยังไม่มีตัวไหนรันอยู่
เช็ก process ก่อนเรียก initialize
ทางแก้ตรงที่สุดคือดูก่อนว่า terminal รันอยู่หรือยัง ถ้ายังไม่มีก็ไม่เรียก initialize() เลย ในงานของผมมีวิธีเช็กอยู่สองแบบ แบบแรกใช้ tasklist กรองด้วยชื่อ image
# เวอร์ชันหยาบ: มี terminal64.exe ตัวไหนก็ได้รันอยู่หรือเปล่า
tasklist /fi "imagename eq terminal64.exe" /fo csv
# เวอร์ชันที่บอกได้ว่าเป็นตัวไหน: ดูพาธเต็มของแต่ละ process
Get-Process terminal64 -ErrorAction SilentlyContinue | Select-Object Id, Path
เวอร์ชันหยาบใช้ได้เมื่อเครื่องมี terminal ติดตั้งอยู่ชุดเดียว แต่เครื่องที่มี terminal ของโบรก A กับโบรก B อยู่คนละโฟลเดอร์ ทั้งสองตัวจะชื่อไฟล์ terminal64.exe เหมือนกัน การเช็กด้วยชื่อ image จะตอบว่า "มีรันอยู่" ได้ทั้งที่ตัวที่รันอยู่เป็นของอีกโบรกหนึ่ง แล้ว initialize(path=) ก็จะเปิดตัวที่ต้องการขึ้นมาเองเหมือนเดิม เดมอนเก็บข้อมูลของผมเลยใช้แบบที่สอง คือเทียบพาธเต็มของไฟล์ exe ทีละ process
import os, time
import psutil
import MetaTrader5 as mt5
TERMINAL = os.environ.get("MT5_PATH", r"C:\terminals\broker-a\terminal64.exe")
def terminal_running(path):
want = os.path.normcase(os.path.abspath(path))
for p in psutil.process_iter(["name", "exe"]):
try:
exe = p.info.get("exe")
if exe and os.path.normcase(os.path.abspath(exe)) == want:
return True
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
return False
def attach(wait_s=1800):
"""ต่อเข้า terminal ที่รันอยู่แล้วเท่านั้น ไม่มีวันเปิดเอง"""
waited = 0
while not terminal_running(TERMINAL):
if wait_s <= 0:
print("[ERR] terminal ไม่ได้รันอยู่ สคริปต์นี้ไม่เปิดให้")
return False
if waited % 300 == 0:
print(f"[WARN] รอ terminal ({waited}s)")
time.sleep(10)
waited += 10
if waited >= wait_s:
print(f"[ERR] รอครบ {wait_s}s แล้ว terminal ยังไม่มา")
return False
if not mt5.initialize(path=TERMINAL):
print("[ERR] attach ไม่สำเร็จ:", mt5.last_error())
return False
return True
normcase กับ abspath จำเป็นทั้งคู่ เพราะพาธบน Windows ไม่สนตัวพิมพ์เล็กใหญ่ และพาธที่มาจากตัวแปรสภาพแวดล้อมกับพาธที่ระบบรายงานอาจเขียนไม่เหมือนกันทั้งที่เป็นไฟล์เดียวกัน
wait_s = 0
เหมาะกับสคริปต์ที่คนสั่งรันเองจากหน้าจอ ถ้า terminal ยังไม่เปิดก็ออกพร้อมข้อความบอกเหตุผล คนที่สั่งจะรู้ทันทีว่าต้องไปเปิด terminal ก่อน ไม่มีอะไรค้างเบื้องหลัง
wait_s = 1800
เหมาะกับเดมอนที่ถูกเรียกตอนเครื่องบูต ซึ่งมักมาถึงก่อน terminal มันเช็กซ้ำทุก 10 วินาที พิมพ์สถานะทุก 300 วินาที และยอมแพ้เมื่อครบ 1800 วินาที ไม่รอไปตลอดกาลแบบไม่มีใครรู้
ต้องยอมรับว่าวิธีนี้ยังมีช่องว่างเล็ก ๆ เหลืออยู่ คือระหว่างบรรทัดที่เช็ก process กับบรรทัดที่เรียก initialize() ถ้า terminal ปิดตัวลงในเสี้ยววินาทีนั้นพอดี ไลบรารีก็ยังเปิดตัวใหม่ได้ ผมไม่มีวิธีปิดช่องนี้จากฝั่ง Python ได้สนิท สิ่งที่ทำได้คือทำให้หน้าต่างแคบที่สุดด้วยการเช็กติดกับการเรียก ไม่มีงานอื่นคั่นกลาง
ปักบัญชีตอนเริ่ม แล้วเทียบทุกรอบ
attach สำเร็จแล้วยังไม่จบ เพราะ terminal ตัวเดียวสลับบัญชีได้ คนที่นั่งหน้าจอสลับไปล็อกอินอีกบัญชีหรืออีกเซิร์ฟเวอร์ได้ตลอดเวลา โดยที่ session ของสคริปต์ยังต่ออยู่ตามปกติ ข้อมูลที่ดึงได้หลังจากนั้นเป็นของอีกเซิร์ฟเวอร์ แต่สคริปต์ยังเขียนลงคลังของเซิร์ฟเวอร์เดิม
ผมเลยให้สคริปต์จดไว้ตอนเริ่มว่ากำลังต่ออยู่กับบัญชีอะไร แล้วเทียบทุกรอบ
ai = mt5.account_info()
PIN = {
"login": int(ai.login) if ai else 0,
"server": ai.server if ai else "",
"trade_mode": int(getattr(ai, "trade_mode", -1)) if ai else -1,
}
def account_still_same():
now = mt5.account_info() # อ่านสดทุกครั้ง ไม่ใช้ค่าที่จำไว้
if now is None:
return False # อ่านไม่ได้ = ไม่ทำงานรอบนี้
if int(now.login) != PIN["login"] or now.server != PIN["server"]:
return False # สลับบัญชีหรือสลับเซิร์ฟเวอร์
if int(getattr(now, "trade_mode", -1)) != PIN["trade_mode"]:
return False # ประเภทบัญชีเปลี่ยน
return True
ค่าที่ปักมีสามตัว เลขบัญชีกับชื่อเซิร์ฟเวอร์ใช้จับการสลับบัญชี ส่วน trade_mode คือรหัสประเภทของบัญชีตามที่ API รายงาน ผมปักไว้ด้วยเพื่อให้สถานการณ์แบบ "เริ่มงานกับบัญชีประเภทหนึ่ง แล้วไปจบที่บัญชีอีกประเภทโดยไม่มีใครสั่ง" เป็นสิ่งที่เกิดไม่ได้ ไม่ใช่แค่เกิดยาก
เมื่อเจอว่าบัญชีเปลี่ยน สิ่งที่ผิดคือทำงานต่อด้วยค่าเดิม เพราะในโปรแกรมมีของหลายอย่างที่ผูกกับเซิร์ฟเวอร์ เช่น offset ของนาฬิกาเซิร์ฟเวอร์ ไฟล์ cache ที่ตั้งชื่อตามเซิร์ฟเวอร์ และปลายทางของคลังข้อมูล ลำดับที่ผมใช้ตอนเจอการสลับเป็นแบบนี้
- หยุดงานที่เขียนข้อมูลหรือส่งคำสั่งงานทั้งหมดก่อนเป็นอย่างแรก ภายใต้ lock ตัวเดียวกับที่งานพวกนั้นใช้ เพื่อไม่ให้มีงานแทรกเข้ามาตอนค่ายังโหลดไม่ครบ
- โหลดค่าที่ผูกกับเซิร์ฟเวอร์ใหม่ทั้งชุด ทั้ง offset ของนาฬิกาและ cache ที่แยกไฟล์ตามเซิร์ฟเวอร์
- ล้างสถานะที่สะสมจากเซิร์ฟเวอร์เดิม เพราะตัวเลขที่จำไว้จากเซิร์ฟเวอร์หนึ่งไม่ใช่หลักฐานของอีกเซิร์ฟเวอร์
- บันทึกค่าปักตัวใหม่เป็นขั้นสุดท้าย ถ้าขั้นไหนก่อนหน้าล้ม ค่าปักยังเป็นของเดิม รอบถัดไปจะเห็นว่ายังไม่ตรงแล้วลองใหม่ทั้งชุด
- ให้คนเป็นผู้สั่งกลับมาทำงานต่อ ไม่กลับมาทำงานเองโดยอัตโนมัติ
ข้อที่สี่คือข้อที่มองข้ามง่ายที่สุด ถ้าบันทึกค่าปักก่อนแล้วค่อยโหลดของอื่น เมื่อการโหลดล้มกลางทาง โปรแกรมจะเชื่อว่าสลับเสร็จแล้วทั้งที่ค่าครึ่งหนึ่งยังเป็นของเซิร์ฟเวอร์เก่า การบันทึกเป็นขั้นสุดท้ายทำให้การสลับทั้งชุดลองใหม่ได้เองโดยไม่ต้องเขียนโค้ดกู้สถานะเพิ่ม
session ตายเงียบหลัง terminal restart
เรื่องที่ทำให้ผมเสียข้อมูลมากที่สุดไม่ใช่ terminal ซ้อน แต่เป็นอาการตรงข้าม คือ terminal ตัวจริงรันอยู่ดี แต่สคริปต์ต่ออยู่กับ session ที่ตายไปแล้ว
เมื่อ terminal ถูกปิดแล้วเปิดใหม่ ไม่ว่าจะโดยคน โดยการอัปเดต หรือโดยการเปลี่ยนค่าที่ต้อง restart ช่องทางสื่อสารระหว่าง process ที่สคริปต์เปิดไว้กับ terminal ตัวเก่าจะค้างอยู่ในสภาพครึ่งตาย terminal ตัวใหม่ทำงานปกติอยู่ข้าง ๆ แต่ทุกคำสั่งที่สคริปต์เรียกจะคืน None หรือ False โดยไม่มี exception เลยสักตัว
อาการที่เห็นจากฝั่งสคริปต์คือ account_info() คืนค่าว่างหรือเลขบัญชีเป็น 0 การขอข้อมูลสัญลักษณ์ได้ None แล้วโค้ดของผมแปลว่า "สัญลักษณ์นี้ไม่มีข้อมูล" แล้วข้ามไป วนอย่างนี้ทุกรอบ เดมอนตัวหนึ่งของผมวิ่งแบบตาบอดอย่างนี้อยู่สามวัน ทุกรอบรายงานว่าทำงานจบ แต่ไม่ได้ข้อมูลใหม่เลย
จุดที่หลอกคือการเช็ก process ที่เพิ่งเขียนไปข้างบนยังผ่านอยู่ เพราะมี terminal รันอยู่จริง ตัวชี้ขาดที่ใช้ได้คือ terminal_info() ถ้ามันคืน None แปลว่า session นี้ไม่มีใครตอบแล้ว
def ensure_session():
"""คืน 'ok' / 'reattached' / 'down' เรียกทุกรอบก่อนทำงาน"""
if mt5.terminal_info() is not None and terminal_running(TERMINAL):
return "ok"
print("[WARN] session ตาย หรือ terminal หายไป กำลังต่อใหม่")
try:
mt5.shutdown() # ทิ้ง session เก่าให้ขาดจริงก่อน
except Exception:
pass
return "reattached" if attach(wait_s=1800) else "down"
while True:
state = ensure_session()
if state == "down":
time.sleep(60) # ลองใหม่ทุก 60 วินาที ไม่ออกจากโปรแกรม
continue
if state == "reattached":
reload_server_state() # ค่าที่วัดไว้จาก session ก่อนอาจไม่จริงแล้ว
if account_still_same():
run_cycle()
time.sleep(300)
ต้องเรียก shutdown() ก่อน initialize() รอบใหม่เสมอ ไม่งั้นไลบรารียังถือ session เก่าอยู่ และค่าที่คืนกลับมาสามแบบมีความหมายต่างกัน สถานะ reattached บอกชั้นบนว่าค่าที่วัดไว้จาก session ก่อนหน้า เช่น offset ของนาฬิกาเซิร์ฟเวอร์ ต้องวัดใหม่ เพราะระหว่างที่ terminal ปิดไป คนอาจสลับบัญชีไปแล้ว
เดมอนรุ่นเก่าของผมใช้ initialize(path=, portable=True) ในฟังก์ชันต่อใหม่ ผลคือเมื่อคนปิด terminal ไปเลยทั้งตัว เดมอนจะเปิดมันกลับขึ้นมาเองในรอบถัดไป ซึ่งในระบบที่มีเดมอนตัวเดียวเป็นพฤติกรรมที่ต้องการ แต่ในเครื่องที่มีโปรแกรมอื่นเป็นเจ้าของ terminal มันคือการแย่งกันเปิด ก่อนจะคัดลอกฟังก์ชันต่อใหม่จากโปรเจกต์หนึ่งไปอีกโปรเจกต์ ต้องตอบให้ได้ก่อนว่าในเครื่องนั้นใครเป็นเจ้าของ terminal
ลำดับตอนเริ่มโปรแกรมที่ต้องเรียงให้ถูก
มีรายละเอียดอีกข้อที่เกี่ยวกัน การ attach ใช้เวลาได้เป็นสิบวินาที และถ้าเปิดโหมดรอ ก็ค้างอยู่ตรงนั้นได้นานถึงครึ่งชั่วโมง ถ้าโปรแกรมมีด่านกันรันซ้อน ด่านนั้นต้องอยู่ก่อนการ attach ไม่ใช่หลัง ไม่งั้นโปรแกรมสองชุดที่ถูกเรียกไล่เลี่ยกันจะไปค้างรออยู่ด้วยกัน แล้วหลุดออกมาเช็กด่านกันรันซ้อนพร้อมกันทั้งคู่
ลำดับตอนเริ่มของเดมอนเก็บข้อมูลที่ผมใช้อยู่เลยเป็น ตรวจว่าคลังข้อมูลปลายทางมีอยู่จริง คว้า lock กันรันซ้อน attach แบบรอ ปักบัญชี แล้วค่อยเข้าลูปทำงาน และโหมดที่รันรอบเดียวแล้วออกไม่ต้องคว้า lock เพราะมันไม่ได้อยู่ยาว
ตัวเลขของงานนี้
สรุปคือ initialize(path=) เป็นคำสั่งที่ทำได้ทั้งต่อและเปิด โปรแกรมที่ต้องการแค่ต่อต้องกันส่วนที่เปิดออกเองด้วยการเช็ก process ก่อน และการเช็ก process อย่างเดียวก็ยังไม่พอจะบอกว่า session ใช้ได้ ต้องถาม terminal_info() ทุกรอบ กับเทียบบัญชีจากค่าที่อ่านสดทุกรอบด้วย ส่วนตัวผมใช้โหมด attach-only กับทุกสคริปต์ที่ไม่ใช่ตัวเรียกโปรแกรมตอน login แล้ว ส่วนเครื่องไหนมี terminal ตัวเดียวและสคริปต์ตัวเดียว จะปล่อยให้ไลบรารีเปิดให้เองก็ได้ครับ ตามลักษณะงานของแต่ละที่ ช่องว่างระหว่างการเช็ก process กับการเรียก initialize ที่ผมยังปิดไม่สนิท ถ้าได้วิธีที่ดีกว่านี้ เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย