🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付

問題的概要

在使用 C# 開發 Windows 表單應用程式時,可能會遇到以下現象。

private async void button1_Click(object sender, EventArgs e)
{
    button1.Enabled = false; // 禁用按鈕

    await Task.Delay(3000); // 3 秒的重處理

    button1.Enabled = true; // 啟用按鈕
}

期望的行為: 即使連續點擊按鈕,也只會執行一次處理
實際的行為: 雖然禁用了按鈕,但處理卻被多次執行

這一現象並非 C# 或 Windows 表單的缺陷,而是 Windows 消息系統的正常運作

為什麼會發生重複執行

Windows 消息佇列的機制

Windows 應用程式是 消息驅動 的運作。用戶的操作(點擊、鍵入等)會被全部當作「消息」堆積到佇列中,然後依次處理。

用戶點擊按鈕兩次

[WM_LBUTTONUP][WM_LBUTTONUP] ← 兩個消息堆積到消息佇列中

消息循環依次處理

每個消息都會觸發 Button_Click 事件

具體的處理流程

// 時序處理流程
// 時刻 T1: 用戶第一次點擊
// → WM_LBUTTONUP 消息添加到佇列中

// 時刻 T2: 用戶第二次點擊(在 T1 幾毫秒後)
// → WM_LBUTTONUP 消息添加到佇列中

// 時刻 T3: 第一次消息處理開始
private async void button1_Click(object sender, EventArgs e)
{
    // 在這裡執行 button1.Enabled = false
    // 但第二次消息已經存在於佇列中
}

// 時刻 T4: 第二次消息處理開始(第一次處理中)
// → 第二次 Button_Click 事件被觸發

驗證代碼

以下代碼可用於確認現象。

private static int eventCount = 0;

private async void button1_Click(object sender, EventArgs e)
{
    int currentEvent = ++eventCount;
    Console.WriteLine($"=== 事件 {currentEvent} 開始 ===");
    Console.WriteLine($"按鈕狀態: {button1.Enabled}");
    Console.WriteLine($"執行緒 ID: {Thread.CurrentThread.ManagedThreadId}");

    button1.Enabled = false;
    Console.WriteLine($"禁用按鈕 (事件 {currentEvent})");

    try
    {
        await Task.Delay(2000);
        Console.WriteLine($"處理完成 (事件 {currentEvent})");
    }
    finally
    {
        button1.Enabled = true;
        Console.WriteLine($"=== 事件 {currentEvent} 結束 ===\n");
    }
}

輸出示例(快速點擊兩次按鈕)

=== 事件 1 開始 ===
按鈕狀態: True
禁用按鈕 (事件 1)
=== 事件 2 開始 ===
按鈕狀態: False
禁用按鈕 (事件 2)
處理完成 (事件 1)
=== 事件 1 結束 ===
處理完成 (事件 2)
=== 事件 2 結束 ===

輸出的順序可能會有所變化,但雖然禁用了按鈕,下一個處理仍然會執行。

微軟的官方見解

1. Control 類別的文件

微軟的官方文件中對 Control.Click 事件有如下描述。

"當控制項被點擊時會引發 Click 事件。當用戶按下並釋放位於控制項上的鼠標按鈕時,此事件將發生。"

也就是說,當控制項處於有效狀態時,每次被點擊都會觸發 Click 事件 是正確的規範。

2. 關於消息佇列的官方說明

Windows API 文件中關於消息佇列的描述為:

"消息是根據它們被張貼到佇列中的順序進行處理的。如果在消息仍然在佇列中時窗口被銷毀,則這些消息將被移除。"

消息會 按照進入佇列的順序來處理,只要窗口未被銷毀,便會繼續處理。

3. .NET Framework 的事件處理

.NET Framework 的事件處理機制是 即使是非同步事件處理程序,事件本身也會依次觸發 的規範。

// 即使事件處理程序是 async void
private async void button1_Click(object sender, EventArgs e)
{
    // 此方法本身是「同步開始」的
    // 在到達 await 之前是同步執行
    await SomeAsyncOperation(); // 這裡才會變為非同步
}

避免方法的種類和比較

1. 旗標基礎控制

用 bool 變數管理目前是否正在處理,若正在處理則提前返回的最簡單方法。

private bool isProcessing = false;

private async void button1_Click(object sender, EventArgs e)
{
    if (isProcessing) return;

    isProcessing = true;
    button1.Enabled = false;

    try
    {
        await ProcessAsync();
    }
    finally
    {
        isProcessing = false;
        button1.Enabled = true;
    }
}

優點: 簡單易懂,輕量
缺點: 多執行緒環境下有競爭狀態的可能性(檢查和設置之間可能被其他處理中斷)

2. 使用 Interlocked 進行原子操作

使用 CPU 層面的原子(不可分割)操作來防止競爭狀態的方法。檢查和設置可同時進行,因此安全。

private int isProcessing = 0;

private async void button1_Click(object sender, EventArgs e)
{
    // 原子地檢查並設置值
    if (Interlocked.CompareExchange(ref isProcessing, 1, 0) != 0)
    {
        return; // 已經正在處理中
    }

    button1.Enabled = false;

    try
    {
        await ProcessAsync();
    }
    finally
    {
        button1.Enabled = true;
        Interlocked.Exchange(ref isProcessing, 0);
    }
}

優點: 线程安全,輕量,高速
缺點: 沒有超時功能,代碼不直觀

3. 使用 SemaphoreSlim 進行控制

使用非同步處理優化的信號量(限制同時執行數量)的方法。高功能且靈活。

private readonly SemaphoreSlim semaphore = new SemaphoreSlim(1, 1);

private async void button1_Click(object sender, EventArgs e)
{
    if (!await semaphore.WaitAsync(0))
    {
        return; // 已經正在處理中
    }

    button1.Enabled = false;

    try
    {
        await ProcessAsync();
    }
    finally
    {
        button1.Enabled = true;
        semaphore.Release();
    }
}

優點: 支援非同步,具有超時功能,支援取消,並行數控制,线程安全
缺點: 較為複雜,資源使用量較多,需 Dispose

4. 使用 lock 語句進行控制

使用 .NET 標準的互斥控制(Monitor)的方法。在同步處理中非常普遍,但在非同步處理時需要注意。

private readonly object lockObject = new object();
private bool isProcessing = false;

private async void button1_Click(object sender, EventArgs e)
{
    bool shouldProcess = false;

    lock (lockObject)
    {
        if (!isProcessing)
        {
            isProcessing = true;
            shouldProcess = true;
        }
    }

    if (!shouldProcess) return;

    button1.Enabled = false;

    try
    {
        await ProcessAsync();
    }
    finally
    {
        lock (lockObject)
        {
            isProcessing = false;
        }
        button1.Enabled = true;
    }
}

優點: 线程安全,.NET 標準,易於理解
缺點: 在非同步方法中使用 lock 不建議,存在死鎖風險

5. 動態刪除和添加事件處理程序

在處理期間暫時刪除事件處理程序,物理性地防止事件觸發的方法。最為可靠,但實現複雜。

private async void button1_Click(object sender, EventArgs e)
{
    // 暫時刪除事件處理程序
    button1.Click -= button1_Click;
    button1.Enabled = false;

    try
    {
        await ProcessAsync();
    }
    finally
    {
        button1.Enabled = true;
        // 重新添加事件處理程序
        button1.Click += button1_Click;
    }
}

優點: 確實能避免重複,不需要額外變數
缺點: 複雜,錯誤時難以恢復,保養性差

推薦的解決方案

依情境推薦的方法

情境 推薦方法 原因
簡單的 UI 操作 基於 Interlocked 輕量、高速、充分的安全性
長時間處理 SemaphoreSlim 超時功能、支援取消
外部資源存取 SemaphoreSlim 並行數控制、充實的錯誤處理
輕量處理 基於旗標 簡單、開銷少

通用實現模式

結合 SemaphoreSlim 的排他控制及超時功能,將處理邏輯和 UI 控制分開,具備例外安全性和資源管理的,實際項目中可直接使用的完整最佳實踐實現範例。

<details><summary>查看範例代碼</summary>

public partial class Form1 : Form
{
    private readonly SemaphoreSlim processingLock = new SemaphoreSlim(1, 1);

    private async void ProcessButton_Click(object sender, EventArgs e)
    {
        // 使用超時檢查處理中狀態
        if (!await processingLock.WaitAsync(TimeSpan.FromSeconds(1)))
        {
            MessageBox.Show("前一個處理尚未完成");
            return;
        }

        try
        {
            await ExecuteProcessAsync();
        }
        finally
        {
            processingLock.Release();
        }
    }

    private async Task ExecuteProcessAsync()
    {
        // 更新 UI 狀態
        UpdateUI(isProcessing: true);

        try
        {
            // 實際處理
            await YourBusinessLogicAsync();
            MessageBox.Show("處理完成");
        }
        catch (Exception ex)
        {
            MessageBox.Show($"錯誤: {ex.Message}");
        }
        finally
        {
            // 恢復 UI 狀態
            UpdateUI(isProcessing: false);
        }
    }

    private void UpdateUI(bool isProcessing)
    {
        processButton.Enabled = !isProcessing;
        progressBar.Visible = isProcessing;
        statusLabel.Text = isProcessing ? "處理中..." : "待機中";
    }

    protected override void Dispose(bool disposing)
    {
        if (disposing)
        {
            processingLock?.Dispose();
        }
        base.Dispose(disposing);
    }
}

</details>

偵錯和故障排除

確認現象的方法

通過詳細的日誌輸出來可視化按鈕重複執行的發生情況的除錯代碼。記錄事件 ID、執行時間、執行緒 ID、按鈕狀態、呼叫堆疊以特定重複執行的時機與原因。

<details><summary>查看範例代碼</summary>

private static int globalEventId = 0;

private async void button1_Click(object sender, EventArgs e)
{
    int eventId = Interlocked.Increment(ref globalEventId);

    Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] 事件 {eventId} 開始");
    Console.WriteLine($"  執行緒: {Thread.CurrentThread.ManagedThreadId}");
    Console.WriteLine($"  按鈕可用: {button1.Enabled}");
    Console.WriteLine($"  堆疊追踪:");

    var stackTrace = new StackTrace();
    for (int i = 0; i < Math.Min(3, stackTrace.FrameCount); i++)
    {
        var frame = stackTrace.GetFrame(i);
        Console.WriteLine($"    {frame.GetMethod()}");
    }

    // 處理的執行...

    Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] 事件 {eventId} 結束");
}

</details>

常見誤解及對策

整理出開發者易陷入的三個典型誤解。

誤解 1:將 Enabled 設為 false 就安全

當用戶快速連續點擊按鈕時,兩個點擊事件會堆積在 Windows 的消息佇列中。即使在第一次處理中禁用按鈕,第二次消息已經進入佇列中,因此會重複執行。僅僅禁用按鈕並不能完全防止重複。

重要要點
消息已經累積在 Windows 的佇列中

誤解 2:async/await 自動進行排他控制

async void 事件處理程序,即使有 await 關鍵字,也不會自動依次執行。當多個事件發生時,每個事件都會獨立地作為 Task 並行執行。async/await 能實現非同步處理,但排他控制需要另外實現。

重要要點
async void 事件處理程序是並行執行的

誤解 3:UI 執行緒因此不會競爭

即便在 UI 執行緒中開始的處理,當到達 await 時便會生成非同步任務,導致多個處理並行執行。同步部分(在 await 之前)是在 UI 執行緒中依次執行的,但非同步部分(在 await 之後)則是作為獨立任務同時執行,因此會出現競爭狀態。

重要要點
非同步處理是以不同任務並行執行的

總結

按鈕的重複執行問題是.....

  1. Windows 消息佇列系統的正常運作
  2. 預期的現象,開發者應該適當控制
  3. 有多種解決方案可選擇,依需求而定

最重要的是了解這一現象不是錯誤而是規範,並選擇適合應用程式需求的控制方法。

對於一般 UI 的「避免重複啟動」,使用 Interlocked.CompareExchange 或簡單旗標在處理開頭進行判斷是輕量而實用的。如果有等待、取消、同時 N 本的需求,則 SemaphoreSlim 是最均衡且擴展性良好的解決方案。

參考連結(官方文件・中文)


原文出處:https://qiita.com/Sakai_path/items/dcc004631ada77bff9a9


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝12   💬8   ❤️4
576
🥈
我愛JS
📝3   💬10   ❤️7
200
🥉
AppleLily
📝1   💬4   ❤️1
69
#5
2
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付