process ลูกค้างเพราะไม่มีใครอ่าน stderr และตายเพราะคนอ่านปิดไปแล้ว
ท่อ stdio มีสองปลาย และพังได้จากทั้งสองปลาย ปลายอ่านที่ไม่ยอมอ่านทำให้คนเขียนค้าง ปลายอ่านที่ตายไปแล้วทำให้คนเขียนล้ม ทั้งสองแบบไม่มี error ที่ชี้มาที่ท่อเลยสักบรรทัด
สรุปใน 1 นาที
- อาการ/ปัญหา
- process ลูกค้างหลังเพิ่ม debug log ลง stderr และอีกแบบคือพังเมื่อปลายอ่านของ pipe ปิดไปแล้ว
- ใครควรอ่าน
- คนที่เขียนโปรแกรมเรียก process ลูกผ่าน stdio หรือ pipe
- ทางแก้แบบสั้น
- ลดข้อความ stderr ให้สั้นแล้วเก็บข้อความเต็มลงไฟล์ และส่ง handle ของไฟล์ให้ลูกสืบทอดแทน pipe เมื่อลูกต้องรันต่อหลังหน้าจอปิด
พอดีผมเพิ่ม debug log ให้เซิร์ฟเวอร์ตัวเล็ก ๆ ตัวหนึ่ง แล้วสคริปต์ smoke test ที่เคยผ่าน 9/9 ก็ค้างนิ่งไปเฉย ๆ ไม่มี error ไม่มี timeout จากฝั่งเซิร์ฟเวอร์ ไม่มีอะไรบน stdout หน้าจอ terminal หยุดอยู่อย่างนั้นจนผมต้องกดฆ่าเอง ต้นเหตุไม่ได้อยู่ในโค้ดที่เพิ่ม แต่อยู่ในท่อที่ไม่มีใครอ่าน และในงานเดียวกันนี้ผมต้องตัดสินใจเรื่องตรงข้ามกันพอดี คือ process ลูกที่ท่อของมันมีคนอ่าน แต่คนอ่านจะปิดไปก่อน
ระบบที่พูดถึงคือโปรแกรมหน้าจอมอนิเตอร์ข้อมูลเวลาจริงที่ผมเขียนด้วย C# ซึ่งผมจะเรียกว่าหน้าจอควบคุม มันสตาร์ต process ลูกตัวหนึ่งที่ผมเรียกว่าเอนจิน และมีเซิร์ฟเวอร์ stdio สำหรับเครื่องมือ AI อีกตัวหนึ่งคั่นให้ผู้ช่วย AI เรียกใช้งานได้ ชื่อไฟล์ ชื่อ process และ path ในบทความนี้ผมตั้งใหม่ทั้งหมด ตัวเลขกับลำดับเหตุการณ์เป็นของจริง
อาการ: smoke test ที่เคยผ่าน 9/9 ค้างนิ่ง
เซิร์ฟเวอร์ stdio คุยกับตัวเรียกแบบง่ายที่สุดเท่าที่จะทำได้ รับ JSON ทีละบรรทัดทาง stdin ตอบ JSON ทีละบรรทัดทาง stdout ส่วน stderr เหลือไว้ให้พิมพ์ข้อความสำหรับคนอ่าน สคริปต์ทดสอบของผมเป็น Python สั้น ๆ เปิดเซิร์ฟเวอร์ขึ้นมา ยิงคำขอ 9 แบบ แล้วเทียบคำตอบ
# smoke.py รุ่นที่ค้าง
import json, subprocess
proc = subprocess.Popen(
["tool-server.exe", "--stdio"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE, # เปิดท่อไว้ แต่ทั้งสคริปต์ไม่มีบรรทัดไหนอ่านมัน
text=True, encoding="utf-8",
)
def call(req: dict) -> dict:
proc.stdin.write(json.dumps(req) + "\n")
proc.stdin.flush()
return json.loads(proc.stdout.readline()) # ค้างอยู่ตรงนี้
สิ่งที่ผมเพิ่มเข้าไปคือ debug log ที่สะท้อนทุก request และ response ลง stderr เพื่อให้เปิดดูได้ว่าผู้ช่วย AI ส่งอะไรมาบ้าง ข้อความหนึ่งบรรทัดยาวได้ถึง 4 KB เพราะมันคือ JSON ทั้งก้อน หลังจากนั้นสคริปต์เดิมก็ค้าง readline() ไม่คืนค่า ฝั่งเซิร์ฟเวอร์ก็เงียบ ไม่มี error ไม่มี timeout ให้อ่านเลยสักบรรทัด
เดาผิดสองรอบก่อนจะมองที่ท่อ
เดาแรกของผมคือ deadlock ใน lock ของตัวเซิร์ฟเวอร์เอง เพราะอาการนิ่งทั้งสองฝั่งโดยไม่มี error มันหน้าตาเหมือน deadlock ที่สุด ผมเลยไปไล่ลำดับการถือ lock ซึ่งไม่ใช่
เดาที่สองคือ request ชนิดใหม่ที่เพิ่มเข้าไปในรอบเดียวกันกำลังรอคำตอบจากเอนจิน ข้อนี้ฟังขึ้นกว่าเพราะเป็นของใหม่จริง แต่ก็ผิดอีก
สิ่งที่ทำให้หลงทางคือทั้งสองเดาเป็นเรื่อง "โค้ดที่เพิ่งแก้" ในขณะที่ตัวจริงเป็นเรื่อง "ปริมาณไบต์ที่เพิ่งเพิ่ม" ซึ่งไม่มีบรรทัดไหนในโค้ดเซิร์ฟเวอร์ผิดเลย
ทั้งสองฝั่งนิ่ง ไม่มี exception ไม่มี timeout และอาการเริ่มหลังจากเพิ่มปริมาณข้อความ ไม่ใช่หลังเปลี่ยนตรรกะ ถ้าเข้าลักษณะนี้ ให้นับก่อนว่า process ลูกมีท่อกี่เส้น และแต่ละเส้นมีใครอ่านอยู่จริงไหม ก่อนจะไปไล่ lock
ตัวจริง: ท่อ stderr เต็มที่ราว 4 KB แล้ว write ก็บล็อก
stderr=subprocess.PIPE สร้าง anonymous pipe ขึ้นมาหนึ่งเส้น ปลายเขียนส่งให้ process ลูก ปลายอ่านอยู่กับสคริปต์ ท่อมีบัฟเฟอร์ขนาดจำกัด บน Windows ถ้าผู้สร้างท่อไม่ระบุขนาดเอง บัฟเฟอร์ที่ได้ในเคสของผมคือราว 4 KB (บน Linux ค่าเริ่มต้นใหญ่กว่ามาก คือ 64 KB อาการแบบเดียวกันเลยโผล่ช้ากว่า แต่โผล่เหมือนกัน) ตราบใดที่บัฟเฟอร์ยังมีที่ว่าง การเขียนก็ผ่านไปทันทีโดยไม่สนว่ามีใครอ่านไหม แต่พอเต็ม การเขียนครั้งถัดไปจะรอจนกว่าปลายอ่านจะดูดข้อมูลออก
log บรรทัดละ 4 KB กับท่อขนาด 4 KB แปลว่าบรรทัดแรก ๆ ก็เต็มแล้ว การ write ลง stderr ครั้งถัดไปของเซิร์ฟเวอร์เลยบล็อกถาวร เพราะสคริปต์ทดสอบไม่มีวันอ่านท่อเส้นนั้น เซิร์ฟเวอร์ที่ติดอยู่ในการเขียน log เลยไปไม่ถึงงานถัดไป ทั้งที่ stdout ซึ่งเป็นช่องงานจริงยังว่าง ฝั่งสคริปต์ก็รอ readline() จาก stdout ที่ไม่มีวันมา สองฝั่งรอกันผ่านท่อคนละเส้น
ฝั่งสคริปต์ทดสอบแก้ง่ายมากครับ แค่เปลี่ยนปลายทางของ stderr จากท่อเป็นไฟล์ ไฟล์ไม่มีบัฟเฟอร์ขนาด 4 KB ให้เต็ม และได้ของแถมคือเปิดอ่านย้อนหลังได้ว่าเซิร์ฟเวอร์บ่นอะไรไว้บ้าง
# smoke.py รุ่นที่แก้: stderr ลงไฟล์ ไม่มีบัฟเฟอร์ให้เต็ม และเปิดอ่านย้อนหลังได้
errlog = open("smoke.stderr.log", "w", encoding="utf-8")
proc = subprocess.Popen(
["tool-server.exe", "--stdio"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=errlog, # หรือ subprocess.DEVNULL ถ้าไม่อยากเก็บ
text=True, encoding="utf-8",
)
ฝั่ง .NET ก็เป็นกับดักเดียวกัน ใครใช้ Process.Start แล้วตั้ง RedirectStandardError = true ต้องมีคนดูดท่อนั้นตลอดอายุของลูก ไม่งั้นลูกค้างเมื่อท่อเต็มแบบเดียวกันเป๊ะ วิธีที่เอกสารของ .NET แนะนำคืออ่านแบบ asynchronous อย่างน้อยหนึ่งเส้น
var psi = new ProcessStartInfo("tool-server.exe", "--stdio")
{
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
using var errLog = TextWriter.Synchronized(new StreamWriter("server.stderr.log", append: true));
using var p = Process.Start(psi)!;
p.ErrorDataReceived += (_, e) => { if (e.Data != null) errLog.WriteLine(e.Data); };
p.BeginErrorReadLine(); // ดูด stderr ทิ้งไว้เบื้องหลัง แล้วค่อยคุยงานทาง stdin/stdout
แก้ฝั่งเซิร์ฟเวอร์: stderr สั้น 240 ไบต์ ข้อความเต็มลงไฟล์
แก้สคริปต์ทดสอบแล้วจบได้ก็จริง แต่สคริปต์ทดสอบไม่ใช่ host ตัวเดียวของเซิร์ฟเวอร์ตัวนี้ host ตัวจริงคือเครื่องมือ AI ของคนอื่น ซึ่งผมไม่รู้ว่ามันอ่าน stderr หรือเปล่า อ่านถี่แค่ไหน หรือเปิดท่อทิ้งไว้เฉย ๆ เหมือนสคริปต์ของผม โปรแกรม stdio ที่ไม่รู้ว่าใครเป็น host ต้องถือว่า stderr อาจไม่มีใครอ่านเลย ผมเลยแก้ฝั่งเซิร์ฟเวอร์ด้วย ข้อความเต็มไปลงไฟล์ log ส่วนสำเนาที่ออก stderr ตัดเหลือ 240 ไบต์ต่อบรรทัด
static readonly object Gate = new();
static StreamWriter? _file; // เปิดครั้งเดียวตอนสตาร์ต AutoFlush = true
static void DebugLine(string line)
{
lock (Gate)
{
try { _file?.WriteLine(line); } // ข้อความเต็ม ลงไฟล์ ไม่มีวันบล็อกเพราะท่อ
catch (IOException) { } // เขียน log ไม่ได้ ต้องไม่ทำให้งานหลักล้ม
Console.Error.WriteLine(ClipUtf8(line, 240));
}
}
// ตัดตามจำนวนไบต์ UTF-8 โดยไม่ผ่ากลางอักขระ
static string ClipUtf8(string s, int maxBytes)
{
if (Encoding.UTF8.GetByteCount(s) <= maxBytes) return s;
int bytes = 0, i = 0;
while (i < s.Length)
{
int len = char.IsHighSurrogate(s[i]) && i + 1 < s.Length ? 2 : 1;
int b = Encoding.UTF8.GetByteCount(s.AsSpan(i, len));
if (bytes + b > maxBytes - 3) break;
bytes += b;
i += len;
}
return s.Substring(0, i) + "...";
}
หลังแก้ทั้งสองฝั่ง smoke test ชุดที่ขยายแล้วผ่าน 11/11
ต้องพูดให้ตรงครับ ท่อ 4 KB หารด้วยบรรทัดละ 240 ไบต์ ได้ราว 17 บรรทัด ถ้า host ไม่อ่าน stderr เลยจริง ๆ บรรทัดที่ 18 ก็ค้างเหมือนเดิม การตัดให้สั้นช่วยกับ host ที่อ่านช้าหรืออ่านเป็นช่วง ไม่ได้ช่วยกับ host ที่ไม่อ่าน สิ่งที่แก้ปัญหาจริงคือข้อความเต็มไปอยู่ในไฟล์ ส่วนตัวผมเลยถือว่า stderr ของโปรแกรม stdio ใช้ได้กับข้อความจำนวนจำกัด เช่น บรรทัดตอนสตาร์ตกับ error ร้ายแรง ส่วน log ที่ไหลตามจำนวนคำขอต้องมีที่อยู่เป็นไฟล์เสมอ
อีกด้านของท่อ: ปลายอ่านที่ตายไปแล้ว
เรื่องที่สองไม่ใช่บั๊ก แต่เป็นการตัดสินใจออกแบบที่ถ้าเลือกผิดจะได้บั๊กแบบกลับด้านกับเรื่องแรก
หน้าจอควบคุมเป็นตัวสตาร์ตเอนจินเป็น process ลูกแบบไม่มีหน้าต่าง และข้อกำหนดคือเอนจินต้องทำงานต่อได้แม้ปิดหน้าจอ หรือหน้าจอ crash ทางที่มือไปเองคือ Process.Start กับ RedirectStandardOutput แล้วอ่านบรรทัดมาแสดงบนจอ ซึ่งใช้ได้ดีตราบใดที่แม่ยังอยู่ แต่วันที่หน้าจอปิด ปลายอ่านของ pipe หายไปพร้อมกับ process แม่ หลังจากนั้นทุกครั้งที่เอนจินพิมพ์อะไรออก console การเขียนจะล้มด้วย error แบบ broken pipe แต่ละภาษาแต่ละ runtime รับมือเรื่องนี้ไม่เหมือนกัน บางตัวกลืนเงียบ บางตัวโยน exception และบางตัว panic ทันที อย่าง println! ของ Rust ที่ panic เมื่อเขียน stdout ไม่สำเร็จ ผลคือ process ที่ตั้งใจให้รอดจากการปิดหน้าจอ กลับตายเพราะบรรทัด log ธรรมดาบรรทัดหนึ่ง
ท่อที่ไม่มีใครอ่าน
- ปลายอ่านยังเปิดอยู่ แต่ไม่ถูกดูด
- บัฟเฟอร์เต็ม คนเขียนค้าง
- ไม่มี error ทั้งสองฝั่ง
- โผล่เมื่อปริมาณ log เพิ่ม
ท่อที่ปลายอ่านตาย
- ปลายอ่านถูกปิดไปพร้อม process แม่
- คนเขียนล้มทันทีที่เขียน
- error อยู่ในลูก ซึ่งไม่มีใครเห็นแล้ว
- โผล่วันที่ปิดหน้าจอขณะลูกยังรัน
สองเรื่องนี้มีทางออกร่วมกันคือเลิกเอาท่อมาคั่นระหว่าง process ที่อายุไม่เท่ากัน ถ้าลูกต้องอยู่นานกว่าแม่ ปลายทางของ stdout ต้องเป็นของที่ไม่ตายตามแม่ ซึ่งก็คือไฟล์
ส่ง handle ของไฟล์ให้ลูกสืบทอด ไม่ใช่ pipe
Process.Start ของ .NET ให้เลือกได้แค่ "ไม่ redirect" กับ "redirect เป็นท่อกลับมาหาแม่" ไม่มีช่องให้บอกว่า stdout ของลูกคือ handle ตัวนี้ ผมเลยเรียก CreateProcessW เอง เปิดไฟล์ log เปิดธงสืบทอดเฉพาะ handle ที่ตั้งใจ ใส่ลงใน STARTUPINFO สตาร์ตลูก แล้วปิดธงคืนทันที
const uint HANDLE_FLAG_INHERIT = 0x1;
const int STARTF_USESTDHANDLES = 0x100;
const uint CREATE_NEW_PROCESS_GROUP = 0x200, CREATE_BREAKAWAY_FROM_JOB = 0x01000000, CREATE_NO_WINDOW = 0x08000000;
const uint GENERIC_READ = 0x80000000, FILE_APPEND_DATA = 0x4;
const uint SHARE_ALL = 0x1 | 0x2 | 0x4, OPEN_EXISTING = 3, OPEN_ALWAYS = 4;
static readonly object SpawnGate = new();
static int StartEngine(string exe, string args, string logPath)
{
using SafeFileHandle hOut = CreateFileW(logPath, FILE_APPEND_DATA, SHARE_ALL, IntPtr.Zero, OPEN_ALWAYS, 0, IntPtr.Zero);
using SafeFileHandle hNul = CreateFileW("NUL", GENERIC_READ, SHARE_ALL, IntPtr.Zero, OPEN_EXISTING, 0, IntPtr.Zero);
if (hOut.IsInvalid || hNul.IsInvalid) throw new Win32Exception();
var si = new STARTUPINFO
{
cb = Marshal.SizeOf<STARTUPINFO>(),
dwFlags = STARTF_USESTDHANDLES,
hStdInput = hNul.DangerousGetHandle(),
hStdOutput = hOut.DangerousGetHandle(),
hStdError = hOut.DangerousGetHandle(),
};
var cmd = new StringBuilder($"\"{exe}\" {args}");
uint flags = CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP;
lock (SpawnGate) // ห้ามมีการ spawn อื่นแทรกระหว่างที่ธงเปิดอยู่
{
SetHandleInformation(hOut, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT);
SetHandleInformation(hNul, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT);
try
{
bool ok = CreateProcessW(null, cmd, IntPtr.Zero, IntPtr.Zero, true /* bInheritHandles */,
flags | CREATE_BREAKAWAY_FROM_JOB, IntPtr.Zero, null, ref si, out PROCESS_INFORMATION pi);
if (!ok) // job ที่ไม่อนุญาต breakaway จะปฏิเสธ ลองใหม่โดยไม่มีธงนั้น
ok = CreateProcessW(null, cmd, IntPtr.Zero, IntPtr.Zero, true, flags, IntPtr.Zero, null, ref si, out pi);
if (!ok) throw new Win32Exception();
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
return pi.dwProcessId;
}
finally
{
SetHandleInformation(hOut, HANDLE_FLAG_INHERIT, 0); // ปิดธงทันที ลูกตัวถัดไปจะได้ไม่พก handle นี้ไปด้วย
SetHandleInformation(hNul, HANDLE_FLAG_INHERIT, 0);
}
}
}
[StructLayout(LayoutKind.Sequential)]
struct STARTUPINFO
{
public int cb;
public IntPtr lpReserved, lpDesktop, lpTitle;
public int dwX, dwY, dwXSize, dwYSize, dwXCountChars, dwYCountChars, dwFillAttribute, dwFlags;
public short wShowWindow, cbReserved2;
public IntPtr lpReserved2, hStdInput, hStdOutput, hStdError;
}
[StructLayout(LayoutKind.Sequential)]
struct PROCESS_INFORMATION { public IntPtr hProcess, hThread; public int dwProcessId, dwThreadId; }
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
static extern SafeFileHandle CreateFileW(string name, uint access, uint share, IntPtr sa, uint disposition, uint flags, IntPtr template);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool SetHandleInformation(SafeHandle h, uint mask, uint flags);
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
static extern bool CreateProcessW(string? app, StringBuilder cmdLine, IntPtr procAttr, IntPtr threadAttr, bool inherit,
uint flags, IntPtr env, string? cwd, ref STARTUPINFO si, out PROCESS_INFORMATION pi);
[DllImport("kernel32.dll")]
static extern bool CloseHandle(IntPtr h);
มีสี่จุดในโค้ดนี้ที่ผมอยากให้ดูให้ชัด
- เปิดธงเฉพาะ handle ที่ตั้งใจ แล้วปิดคืน
bInheritHandles = trueแปลว่าลูกได้ handle ทุกตัวที่ติดธงสืบทอดอยู่ ณ วินาทีนั้น ไม่ใช่แค่สามตัวใน STARTUPINFO ถ้าเปิดธงทิ้งไว้ process ลูกทุกตัวที่ spawn ทีหลังจะถือไฟล์ log นี้ค้างไปด้วย ไฟล์จะลบไม่ได้ หมุนไม่ได้ จนกว่าลูกตัวสุดท้ายจะตาย - ล็อกช่วงที่ธงเปิดอยู่ ถ้า thread อื่น spawn อะไรในจังหวะนั้นพอดี มันจะได้ handle ติดไปด้วย ถ้าต้องการความแม่นกว่านี้ Windows มีรายการ handle ที่ระบุให้สืบทอดเป็นรายตัวผ่าน attribute list ของ process ซึ่งผมยังไม่ได้ใช้ในงานนี้
- stdin ชี้ไป NUL ลูกที่ไม่มีหน้าต่างและไม่มี stdin ที่ถูกต้อง อาจได้ handle ที่ใช้ไม่ได้ การให้ NUL ไปเลยทำให้การอ่าน stdin ได้ EOF แบบสะอาด
- breakaway แล้ว fallback ธง
CREATE_BREAKAWAY_FROM_JOBทำให้ลูกไม่ถูกลากตายไปกับ job ของแม่ แต่ถ้า job นั้นไม่อนุญาต การสร้าง process จะล้มทั้งคำสั่ง เลยต้องลองซ้ำแบบไม่มีธง รายละเอียดเรื่อง job object ผมเขียนแยกไว้แล้วใน บทความเรื่องงานตายตอนปิด SSH
ไฟล์ถูกเปิดด้วยสิทธิ์ FILE_APPEND_DATA อย่างเดียว การเขียนทุกครั้งผ่าน handle นี้เลยไปต่อท้ายไฟล์เสมอ ส่วนธง CREATE_NEW_PROCESS_GROUP มีผลข้างเคียงที่ต้องการพอดี คือ Ctrl+C ที่กดในคอนโซลของแม่จะไม่ถูกส่งต่อไปฆ่าลูก
หน้าจอดู log สดด้วยการ tail ไฟล์เดียวกัน
พอไม่มีท่อ หน้าจอก็ไม่ได้รับบรรทัดของเอนจินมาโดยตรงอีกแล้ว แต่มันไม่จำเป็นต้องได้ หน้าจอแค่เปิดไฟล์เดียวกันนั้นอ่านตามไป ข้อสำคัญคือต้องเปิดด้วย FileShare.ReadWrite เพราะมี process อื่นถือสิทธิ์เขียนอยู่ ถ้าขอแชร์แค่ Read การเปิดจะถูกปฏิเสธ
sealed class LogTail : IDisposable
{
private readonly StreamReader _rd;
public LogTail(string path)
{
var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
_rd = new StreamReader(fs, Encoding.UTF8);
}
// เรียกจาก timer ของหน้าจอ: คืนบรรทัดที่เพิ่มมาตั้งแต่ครั้งก่อน
public IEnumerable<string> Poll()
{
string? line;
while ((line = _rd.ReadLine()) != null) yield return line;
}
public void Dispose() => _rd.Dispose();
}
// ตอนเอนจินตายเอง: คัดลอก 40 บรรทัดท้ายเข้า log ของ host
static string[] LastLines(string path, int n)
{
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
using var rd = new StreamReader(fs, Encoding.UTF8);
var q = new Queue<string>(n + 1);
string? line;
while ((line = rd.ReadLine()) != null)
{
q.Enqueue(line);
if (q.Count > n) q.Dequeue();
}
return q.ToArray();
}
แบบนี้ได้ของแถมสองอย่างที่ท่อให้ไม่ได้ อย่างแรก หน้าจอที่เปิดขึ้นมาทีหลังอ่านย้อนหลังได้ตั้งแต่บรรทัดแรก ท่อให้ได้แค่ของที่ไหลมาหลังจากต่อท่อ อย่างที่สอง ตอนเอนจินตายเอง host คัดลอก 40 บรรทัดท้ายของไฟล์นั้นเข้า log ของตัวเอง เหตุผลการตายเลยไปอยู่ในที่เดียวกับเหตุการณ์อื่นของ host โดยไม่ต้องเปิดสองไฟล์เทียบเวลา ข้อที่ต้องรู้ไว้คือการ tail แบบนี้อาจได้บรรทัดที่ยังเขียนไม่จบถ้าอ่านตรงจังหวะพอดี ซึ่งสำหรับการแสดงผลบนจอผมรับได้
ตัว tee ต้องตายเป็นคนสุดท้าย
ยังมีอีกที่หนึ่งที่ท่อแอบกลับเข้ามา คือเวลารันโปรแกรมด้วยมือแล้วอยากเห็นทั้งบนจอและเก็บลงไฟล์ ผมใช้ตัว tee คั่น ซึ่งก็คือ pipe จากโปรแกรมหลักมาที่ tee นั่นเอง ถ้า tee ตายก่อน stdout ของโปรแกรมหลักก็พังแบบเดียวกับเรื่องที่สองทุกประการ ตัว tee เลยมีกติกาสองข้อ ต้องไม่ตายตอนเปิดหรือเขียนไฟล์ไม่ได้ และต้องเมิน Ctrl+C เพราะ Ctrl+C ในคอนโซลถูกส่งให้ทุก process ที่ผูกกับคอนโซลนั้นพร้อมกัน ถ้า tee ตายตามทันที บรรทัด shutdown ของโปรแกรมหลักจะไม่มีที่ไป
# tee_log.py — ใช้: program.exe 2>&1 | python tee_log.py run.log
import signal, sys
signal.signal(signal.SIGINT, signal.SIG_IGN) # ให้โปรแกรมหลักปิดตัวเองให้จบก่อน เราออกเมื่อได้ EOF
try:
f = open(sys.argv[1], "ab")
except OSError as e:
f = None
sys.stderr.write(f"[tee] เปิดไฟล์ไม่ได้ ({e}) ทำงานต่อแบบแสดงผลอย่างเดียว\n")
out = sys.stdout.buffer
for raw in sys.stdin.buffer: # อ่านเป็นไบต์ ไม่ต้องกลัว decode พัง
if out is not None:
try:
out.write(raw)
out.flush()
except OSError:
out = None # จอหายไปแล้ว ก็ยังต้องดูดท่อต่อ
if f is not None:
try:
f.write(raw)
f.flush()
except OSError:
f = None # ดิสก์เต็มหรือไฟล์ถูกล็อก ก็ยังต้องดูดท่อต่อ
หลักคิดเดียวของสคริปต์นี้คือ ไม่ว่าอะไรจะพัง มันต้องอ่าน stdin ต่อไปจนถึง EOF เพราะหน้าที่จริงของมันไม่ใช่การเขียนไฟล์ แต่เป็นการเป็นปลายอ่านที่ไม่ทิ้งคนเขียน
ตัวเลขและกฎที่ผมใช้ต่อจากงานนี้
สรุปคือท่อเป็นสัญญาระหว่างสอง process ที่ต้องมีชีวิตอยู่พร้อมกันและทำหน้าที่ของตัวเองตลอด ฝั่งที่เปิดท่อรับต้องอ่าน ฝั่งที่เขียนต้องรู้ว่าถ้าไม่มีคนอ่านตัวเองจะค้าง เวลาเขียนโปรแกรม stdio ผมถือว่า stderr อาจไม่มีใครอ่านเลย เวลาเขียนตัวเรียก ผมไม่เปิดท่อที่ตัวเองไม่ได้ตั้งใจจะอ่าน และเวลา process ลูกต้องอยู่นานกว่าแม่ ผมให้มันเขียนลงไฟล์ผ่าน handle ที่สืบทอดไป ใครจะเอาโค้ด CreateProcessW ข้างบนไปใช้ก็ปรับชื่อไฟล์กับธงตามงานของตัวเองได้ตามสะดวกครับ ส่วนเรื่องรายการ handle แบบระบุรายตัวที่ผมยังไม่ได้ลอง เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



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