.NET 小技 - 如何避免被同步式 API 呼叫永久卡死?為 Task.Run 設定逾時上限
| | | 0 | |
前陣子用 C# 寫了 Prometheus Exporter 監測智慧插座耗電狀況,借助 prometheus-net 與 TPLinkSmartDevice.NETCore 程式庫,用 50 行程式輕鬆搞定。
using Prometheus;
using System.Diagnostics;
using TPLinkSmartDevices.Devices;
// 讀取設定,提供預設值
var host = Environment.GetEnvironmentVariable("HS300_DeviceIP") ?? throw new ArgumentNullException("HS300_DeviceIP", "請提供 HS300 裝置的 IP 位址");
var metricServer = new MetricServer(port: 9999); // 指定 Exposure Port
metricServer.Start();
Metrics.SuppressDefaultMetrics(); // 停用預設指標
// 建立自訂指標
var myGauge = Metrics.CreateGauge("hs300_stats", "HS300 插座耗電量(w)", new GaugeConfiguration
{
LabelNames = new[] { "type" }
});
// 以背景執行方式更新指標
await Task.Run(async () =>
{
var rand = new Random();
while (true)
{
double[] data = new double[6];
long waitMs = 10_000;
try
{
Console.Write(DateTime.Now.ToString("MM-dd HH:mm:ss "));
var sw = Stopwatch.StartNew();
var hs300 = new TPLinkSmartStrip(host);
for (int i = 0; i < 6; i++)
{
var powerData = hs300.ReadRealtimePowerData(i + 1);
data[i] = powerData.Power;
myGauge.WithLabels($"插座{i + 1}").Set(powerData.Power);
}
sw.Stop();
waitMs = Math.Max(waitMs - (int)sw.ElapsedMilliseconds, 0);
Console.WriteLine($"Read data: {string.Join(",", data)} ({sw.ElapsedMilliseconds:n0}ms)");
}
catch (Exception ex)
{
Console.WriteLine($"錯誤: {ex.Message}");
}
await Task.Delay(TimeSpan.FromMilliseconds(waitMs));
}
});
但運行一段時間發現程式有問題,常會在執行幾天後數值停止更新,從 http://<host-ip>:9300/metrics 仍能讀到資料代表 MetricServer 功能正常,但耗電量停在某個時點不再改變,感覺是 while 迴圈永久卡死了。
使用 docker logs hs300-exporter 查看,未發現錯誤訊息,Log 停留在某個時點,如以下兩起案例:
...
11-26 06:40:20 Read data: 2.138,0,0,0,0,0 (1,454ms)
11-26 06:40:30 Read data: 2.028,0,0,0,0,0 (1,589ms)
11-26 06:40:40 Read data: 2.188,0,0,0,0,0 (63,126ms)
...
11-28 06:49:40 Read data: 2.255,0,0,0,0.011,0 (1,392ms)
11-28 06:49:50 Read data: 2.075,0,0,0,0.004,0 (1,253ms)
程式印完某筆資料後停止輸出,沒看到 catch 捕捉及顯示錯誤訊息。Task.Delay(-1) 陷入永遠等待是種解釋,但依邏輯 waitMs 怎麼都不可能變成 -1,直接排除。下個嫌犯是在 new TPLinkSmartStrip(host) 或 hs300.ReadRealtimePowerData(i + 1) 兩處卡死。但若卡在這兩處,至少應會看到 Console.Write(DateTime.Now.ToString("MM-dd HH:mm:ss ")) 印完日期才對,感覺不合理,不過再深想一層確實有可能。我是用 docker logs 查看輸出內容,Docker 日誌輸出有「行緩衝(Line-Buffered)」機制,輸出資料會先存在緩衝區直到遇到換行符號或緩衝區滿才真的輸出。(註:Docker 使用 json-file logging driver 為非阻塞模式,依賴應用程式的 stdout 行為,因此繼承 glibc 的行緩衝特性。參考)由此可知,程式極有可能是卡死在 TPLinkSmartStrip 的 API,時間部分輸入但因跑不到 WriteLine() 沒出現。第一起事故還有另一條線索:原本讀取六個插座值只要 1.5s,但出錯前花了超過一分鐘,意味 hs300.ReadRealtimePowerData(i + 1) 確實可能出現執行過久的狀況。
Home Assistant 討論區有 HS300 因過度查詢後因過載而陷入無回應狀態的案例,TPLinkSmartStrip 程式庫若依賴 .NET TCPClient、NetworkStream.Read 收資料,TcpClient.ReceiveTimeout 預設為 0,NetworkStream.ReadTimeout 預設值是 Infinite,都會等到地老天荒,至死不渝。
找到原因了,有兩種解法:1) 追至 TPLinkSmartStrip 原始碼修 Bug 2) 呼叫 ReadRealtimePowerData() 加上逾時期限,等太久就放棄。前者比較有趣,但後者是很實用的技巧,為某些可能執行過久甚至永無回應的同步式呼叫設下停損點,這次我決定學習及演練後者。
.NET 對非同步作業的支援很成熟,使用 Task.Run 與 CancellationTokenSource 便能指定逾時上限,迴圈中用 cts.Token.ThrowIfCancellationRequested(); 偵測逾時跳出,若不幸卡死在 hs300.ReadRealtimePowerData(i + 1),await readTask.WaitAsync(cts.Token); 會等待結束或逾時是第二道防線。程式碼修改如下:
var rand = new Random();
while (true)
{
double[] data = new double[6];
try
{
if (DateTime.Now.Second % 10 == 0)
{
Console.Write(DateTime.Now.ToString("MM-dd HH:mm:ss "));
Console.Out.Flush();
var sw = Stopwatch.StartNew();
// 設定 30 秒自動取消
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var readTask = Task.Run(() =>
{
var hs300 = new TPLinkSmartStrip(host);
for (int i = 0; i < 6; i++)
{
cts.Token.ThrowIfCancellationRequested(); // 若已逾時則拋出例外
var powerData = hs300.ReadRealtimePowerData(i + 1);
data[i] = powerData.Power;
myGauge.WithLabels($"插座{i + 1}").Set(powerData.Power);
}
}, cts.Token);
// 等待執行結束或逾時
await readTask.WaitAsync(cts.Token);
sw.Stop();
Console.WriteLine($"Read data: {string.Join(",", data)} ({sw.ElapsedMilliseconds:n0}ms)");
}
}
catch (OperationCanceledException) // 捕捉逾時錯誤
{
Console.WriteLine("錯誤: 讀取逾時");
}
catch (Exception ex)
{
Console.WriteLine($"錯誤: {ex.Message}");
}
await Task.Delay(TimeSpan.FromSeconds(1));
}
以上是較細緻講究的做法,還有一種更簡單粗暴的改法,不需要設 CancellationTokenSource,讀取迴圈過程不檢查,純粹就是在外部 await readTask.WaitAsync(TimeSpan.FromSeconds(30)); 設定時間上限,也有相似效果:
var readTask = Task.Run(() =>
{
var hs300 = new TPLinkSmartStrip(host);
for (int i = 0; i < 6; i++)
{
var powerData = hs300.ReadRealtimePowerData(i + 1);
data[i] = powerData.Power;
myGauge.WithLabels($"插座{i + 1}").Set(powerData.Power);
}
});
await readTask.WaitAsync(TimeSpan.FromSeconds(30));
程式改版上線後,跑了一週沒再出過問題,且在 Log 有看到逾時錯誤,證實修改有效,結案。

Shows how to add a timeout to synchronous API calls in .NET to avoid indefinite waiting, using Task and CancellationToken for reliability.
Comments
Be the first to post a comment