ทำงานหนักๆ โดยที่โปรแกรมไม่ค้าง พร้อมแสดง Progress!

Posted 25/04/2008 02:24 by nantcom

หลังจากเขียนโปรแกรมไปเรื่อยๆ คงต้องมีบ้างละครับ ที่พบว่า เราจะต้องทำการประมวลผลอะไรบางอย่างนานมาก แล้วพอเราทำเข้าจริงๆ โปรแกรมก็จะค้าง และคนใช้เขาก็จะคิดว่าโปรแกรมมัน Hang ไปแล้ว และกด End Task ไป ทั้งที่โปรแกรมเราไม่ได้ผิดอะไร แค่ทำงานอยู่เท่านั้นเอง Smile

อย่างเช่น ผมต้องการทำอะไรไร้สาระบางอย่าง แบบนี้

private void button1_Click(object sender, EventArgs e)
{
    this.progressBar1.Value = 0;
    this.progressBar1.Maximum = 10000;
    for (int i = 0; i < 10000; i++)
    {
        double d = Math.Sqrt(Math.Pow( 0.02 * 0.3 * DateTime.Now.Ticks, i ));
        this.progressBar1.Value++;
    }
}

image

แน่นอนว่า รันโปรแกรม แล้วกดปุ่มปุ๊บ ค้างเลยแน่นอน และผลลัพทธ์ที่ได้ ก็อาจต่างกันไปตามแต่บุญทำกรรมแต่ง บางเครื่อง Progress Bar อาจจะวิ่ง บางเครื่องอาจจะค้าง แต่ที่แน่ๆ ถ้ากดปุ่ม Start ตอนนี้ ภาพปุ่ม Start มันก็จะค้างคาอยู่บนโปรแกรมเรา แบบที่เห็นในรูปนั่นละ (สังเกตว่า ไม่ได้กด Start ไว้แล้ว)

สาเหตุที่มันค้าง ก็เพราะว่า โปรแกรมเรา ไปติดอยู่ในฟังก์ชั่น button1_click อยู่นั่นเอง ไม่สามารถตอบรับคำขอ หรือคำสั่งของ Windows ให้วาดหน้าจอใหม่ หรือตอบสนองการกดปุ่มได้

แล้วจะแก้ยังไงละ ถ้าต้องทำจริงๆ???

การแก้อาการนี้นั้น ง่ายมากครับ เพิ่มบรรทัดเดียว หายเลย

private void button1_Click(object sender, EventArgs e)
{
    this.progressBar1.Value = 0;
    this.progressBar1.Maximum = 10000;
    for (int i = 0; i < 10000; i++)
    {
        double d = Math.Sqrt(Math.Pow( 0.02 * 0.3 * DateTime.Now.Ticks, i ));
        this.progressBar1.Value++;

        Application.DoEvents();
    }
}

การสั่ง Application.DoEvents นั้น ก็คือการบอกให้โปรแกรมของเรา ตอบรับคำขอ และคำสั่งจาก Windows ก่อนนั่นเอง ซึ่งระหว่างนั้น โปรแกรมเราก็จะหยุดทำงานไปก่อนแป๊บนึง และไปทำงานในฟังก์ชั่น DoEvents ก่อน แล้วกลับมาทำงานต่อ ซึ่งทำให้โปรแกรมเรากลับมาตอบสนองได้อีกครั้งอย่างเหลือเชื่อ

เย หายแล้ว ไม่ต้องอ่านต่อแล้วดิ ท่าทางจะยาว...

อย่าลืมว่า แทนที่โปรแกรมเราจะ "DoEvents" เฉพาะเวลาที่มีคนกดปุ่ม มีคนแตะหน้าจอ หรือว่า Windows สั่งให้วาดจอใหม่ กลายเป็นว่า โปรแกรมเรา จะกิน CPU และแบตกว่าเดิม เพราะมันจะพยายามลองเช็คกับ Message Queue ตลอดเวลา ว่า Windows มีคำขอ หรือคำสั่งอะไร รอจะส่งให้โปรแกรมเราหรือเปล่า

และยังสนุกได้มากกว่านั้นอีก... ถ้าสมมุติว่า ผมเพิ่มโค๊ดเข้าไปให้ปุ่มที่สองมันทำงานบ้าง เหมือนปุ่มแรก

private void button2_Click(object sender, EventArgs e)
{
    this.progressBar2.Value = 0;
    this.progressBar2.Maximum = 10000;
    for (int i = 0; i < 10000; i++)
    {
        double d = Math.Sqrt(Math.Pow(0.02 * 0.3 * DateTime.Now.Ticks, i));
        this.progressBar2.Value++;

        Application.DoEvents();
    }
}

แน่นอนว่า ก็ตอนนี้โปรแกรมเราสามารถรับ Input ได้ โดยที่ไม่ค้างแล้ว ผมก็สามารถกดปุ่มแรก ปล่อยให้มันทำงานไปแป๊บนึง แล้วกดปุ่มที่สอง ทำแบบนี้กี่รอบก็ได้ แต่ถ้าสังเกตดู จะเห็นว่า...

image

ปุ่มแรก มันจะหยุด ปล่อยให้ปุ่มที่สองทำงานก่อน ถ้าผมกลับไปกดปุ่มแรกอีก แทนที่ปุ่มแรกจะทำงานต่อ มันกลับไปเริ่มที่ 0 ใหม่ แล้วปุ่มที่สองก็หยุด ถ้าลองดูใน Stack Trace แล้วจะเข้าใจครับ ว่าเพราะอะไร...

image

จะเห็นว่า Application.DoEvents() นั้นเป็นคนมาเรียกให้ button2_click ทำงาน แล้ว button2_click ก็สั่ง Application.DoEvents() ซึ่งทำให้ button1_click ทำงานอีก วนไปเรื่อยๆ ไม่รู้จบถ้าผมยังพยายามกดปุ่มต่อไป เนื่องจากอาจจะงงว่า เอ๊ะ ทำไมงานแรกหยุด มาทำงานที่ 2 อย่างเดียว แล้วพอกดให้งานแรกทำ งานที่สองก็หยุด

นั่นก็เพราะว่า DoEvents() จะทำการตรวจสอบว่า มีคำสั่ง "Click" รออยู่หรือเปล่า ถ้ามี มันก็จะทำการเรียก Event ที่ตอบสนองคำสั่งนั้น ให้ทำงาน นั่นก็คือ buttonXX_click นั่นเอง ถ้าลองเอา Code จากการกดปุ่มแรก กดปุ่มที่สอง แล้วกลับไปกดปุ่มแรกอีกครั้ง มายำรวมกัน ก็จะเป็นแบบนี้

private void button1_Click(object sender, EventArgs e)
{
    this.progressBar1.Value = 0;
    this.progressBar1.Maximum = 10000;
    for (int i = 0; i < 10000; i++)
    {
        double d = Math.Sqrt(Math.Pow( 0.02 * 0.3 * DateTime.Now.Ticks, i ));
        this.progressBar1.Value++;

        //Application.DoEvents();
        // if you click button 2 -> button2_Click runs
        this.progressBar2.Value = 0;
        this.progressBar2.Maximum = 10000;
        for (int i = 0; i < 10000; i++)
        {
            double d = Math.Sqrt(Math.Pow(0.02 * 0.3 * DateTime.Now.Ticks, i));
            this.progressBar2.Value++;

            //Application.DoEvents();
            // if you click button 1 again -> button1_Click runs
            this.progressBar1.Value = 0;
            this.progressBar1.Maximum = 10000;
            for (int i = 0; i < 10000; i++)
            {
                double d = Math.Sqrt(Math.Pow(0.02 * 0.3 * DateTime.Now.Ticks, i));
                this.progressBar1.Value++;

                Application.DoEvents();
            }
        }
    }
}
 

ท้ายที่สุดแล้ว ผมจะเห็น ProgressBar1 วิ่งจนเต็ม 1 รอบ จากนั้น ProgressBar2 ที่ค้างอยู่ ก็จะเริ่มวิ่งต่อจนเต็มอีกครั้ง แล้วสุดท้าย ProgressBar1 ก็จะเด้งมาเป็นค่าค่านึง ที่ไม่ใช่ 0 แล้ววิ่งต่อจนครบ

งานเข้าอีกแล้วสิครับพี่น้อง!

จะเห็นว่า การแก้ง่ายๆ ด้วยการใส่ DoEvent เนี่ย ไม่ work!!! มันเป็นการสร้างภาพ ให้เหมือนว่าโปรแกรมเราไม่ค้างเท่านั้น แถมยังทำให้คนใช้งงด้วย ทางที่ถูกต้องจริงๆ มีทางเดียวคือ ต้องเปิด Thread เท่านั้นครับ เป็นฟังก์ชั่นวายร้ายแห่งปีเลยทีเดียว เจ้า DoEvents เนี่ย

คุณอาจจะคิดว่า อ้าว งั้นก็ Disable ปุ่มที่สองซะสิ จะได้ไม่ต้องมีปัญหา...ก็ถูกครับ มันป้องกันปัญหาได้ แต่เราไม่ได้แก้ปัญหาครับ ปัญหามันมีอยู่ แต่ไม่ได้เกิดขึ้นเท่านั้นเอง... และที่สำคัญ ถ้าเราไม่สามารถแทรก DoEvents ไปให้มันทำงานได้ มันก็ค้างอยู่ดี เช่น...

private void DoHard()
{
    for (int i = 0; i < 10000; i++)
    {
        double d = Math.Sqrt(Math.Pow(0.02 * 0.3 * DateTime.Now.Ticks, i));
    }
}

private void button1_Click(object sender, EventArgs e)
{
    this.DoHard();
}

 

ก็คือ ถ้าเราต้องไปเรียก API หรือ Library แล้วเราจะไปแทรก DoEvents ไว้ ได้ยังไงละ จริงมั๊ย?

ก็เปิด Thread ไม่เป็นนี่ ยากจะตาย

ไม่ต้องห่วงครับ การใช้ Thread นั้น ง่ายกว่าเขียน UI ให้มันฉลาดๆ อีก ลองดูโค๊ดนี้สิครับ

private void DoHard()
{
    for (int i = 0; i < 10000; i++)
    {
        double d = Math.Sqrt(Math.Pow(0.02 * 0.3 * DateTime.Now.Ticks, i));
    }
}

private void button1_Click(object sender, EventArgs e)
{
    Thread t = new Thread(new ThreadStart(this.DoHard));
    t.Start();
}

เสร็จแล้วครับ หลังจากกดปุ่ม คุณก็มี Thread อีก Thread นึง ทำงานแล้ว...!!! (อย่าลืม using System.Threading ก่อน) แล้วหลังจากนั้น ทุกคนส่วนใหญ่จะตกหลุมดำ หลุมเดียวกันเลยครับ นั่นคือ การพยายามทำอะไรแบบนี้

private void DoHard()
{
    for (int i = 0; i < 10000; i++)
    {
        double d = Math.Sqrt(Math.Pow(0.02 * 0.3 * DateTime.Now.Ticks, i));
        this.progressBar1.Value = i; // ***
    }
}

หลังจากรันโปรแกรมปุ๊บ มันก็จะแสดง Exception เลยครับ ยังดีหน่อยที่ .NET CF 3.5 นี่มันแสดงผล Exception ที่บอกตรงๆ เลยว่า เราไม่สามารถทำอะไรกับ UI ได้จาก Thread อื่น พร้อมบอกด้วยว่าจะต้องแก้ยังไง แต่ก่อน มันเป็น Exception อื่นครับ แล้วคนที่เพิ่งเริ่มเล่น Thread (ผมด้วย) ก็เดาไม่ออกเลยว่าจะต้องแก้ยังไง แถมบางที มันยังใช้ได้ โดยที่ไม่พังอีกต่างหาก

image

เห็นมั๊ย ยากจริงๆ ด้วย

ไม่เลยครับ วิธีแก้นั้นก็แสนจะง่าย ลองดูนี่ครับ

    1 // Delegate (function pointer)
    2 delegate void UpdateProgressDelegate(int i);
    3 
    4 // of this method
    5 private void UpdateProgress(int i)
    6 {
    7     this.progressBar1.Value = i;
    8 }
    9 
   10 private void DoHard()
   11 {
   12     UpdateProgressDelegate degl = new UpdateProgressDelegate(this.UpdateProgress);
   13 
   14     for (int i = 0; i < 10000; i++)
   15     {
   16         double d = Math.Sqrt(Math.Pow(0.02 * 0.3 * DateTime.Now.Ticks, i));
   17         this.Invoke(degl, i);
   18     }
   19 }

อย่าเพิ่งงงนะครับ ลองมาไล่กันทีละบรรทัด

บรรทัดที่ 2 เป็นการประกาศ Delegate ชนิดใหม่ ที่สามารถใช้อ้างอิงถึงฟังก์ชั่น ใดๆ ก็ได้ ที่ return ค่า void และรับ พารามิเตอร์ 1 ตัว คือ int ชื่อว่า i
บังเอิญว่า ฟังก์ชั่นที่หน้าตาเหมือนกับ Delegate ที่เราประกาศนี้ ก็คือฟังก์ชั่น UpdateProgress ในบรรทัดที่ 5 นั่นเอง

จากนั้น บรรทัดที่ 12 เราก็สร้าง Instance ของ Delegate นั้น โดยให้มันอ้างถึงฟังก์ชั่น Update Progress

และบรรทัดที่ 17 เราก็ใช้ this.Invoke เรียกให้ degl ทำงาน พร้อมส่งค่า i เป็นค่าของพารามิเตอร์ตัวแรก

มันก็ดูเยอะเหมือนกันนะ...!

ถ้าเข้าใจแล้ว ก็ ลืมโค๊ดตรงนี้ไปเลยครับ ไม่ต้องใช้แล้ว...Indifferent แค่เข้าใจก็พอว่า Delegate มันคืออะไร ทำหน้าที่อะไร และจะใช้งานมันยังไง...เพราะนั่นเป็นการเขียนแบบเก่าครับ การเขียนแบบ C# 2.0 นั้น ทำได้แบบนี้ครับ

    1         private void DoHard()
    2         {
    3             for (int i = 0; i < 10000; i++)
    4             {
    5                 double d = Math.Sqrt(Math.Pow(0.02 * 0.3 * DateTime.Now.Ticks, i));
    6 
    7                 ThreadStart t = delegate
    8                 {
    9                     this.progressBar1.Value = i;
   10                 };
   11                 this.BeginInvoke(t);
   12             }
   13         }

บรรทัดที่ 7 ถึง 10 หมายความว่า "สร้าง Delegate ที่หน้าตาแบบ ThreadStart ขื่อว่า T และให้มีโค๊ดตามบรรทัดที่ 9" ซึ่งเราเรียกโค๊ดแบบนี้ว่า Anonymous Method ครับ

หมายเหตุ: ThreadStart มี Signature คือ void Method() หรือฟังก์ชั่นที่ไม่ Return ค่า และไม่รับพารามิเตอร์เลย นั่นเอง เราไม่จำเป็นต้องใช้ ThreadStart ก็ได้ครับ สามารถใช้ Delegate ไหนก็ได้ที่เหมาะสม หรือจะสร้างใหม่ขึ้นมาก็ได้ ถ้ากลัวว่าคนมาอ่านทีหลังจะงง

จากนั้น ในบรรทัดที่ 11 ผมเรียกใช้ this.BeginInvoke(t) เพื่อสั่งให้ Delegate นั้น ทำงาน ส่วนที่ผมใช้ BeginInvoke ก็เพราะว่า โค๊ดในการทำงาน DoHard ของเรา สามารถทำงานต่อได้เลย โดยไม่ต้องรอให้การแสดงผลของ ProgressBar เรียบร้อยซะก่อน ซึ่งจะเร็วกว่าการใช้ Invoke เพราะถ้าใช้ Invoke ตัว Thread นั้น ก็จะติดอยู่ที่บรรทัดนั้น จนกว่า ProgressBar จะ Update เสร็จเรียบร้อย ทำงานแยกกันโดยสมบูรณ์เลย เห็นมั๊ยครับ

สำหรับใครที่สงสัยว่า แล้ว Method นี่มันไปสร้างไว้ไหน แล้วค่า i มันตามไปได้อย่างไร ในเมื่อ i เปลี่ยนตลอด ลองหาอ่านเรื่อง Anonymous Method ได้จากในเน็ตละครับ Big Smile

เทคนิคเล็กๆ น้อยๆ เสริมท้าย

การให้ผู้ใช้เขาเห็นว่า โปรแกรมกำลังทำงานอยู่แบบสดๆ มันก็ดูเจ๋งดีครับ แต่ถ้าจะให้ดี เราไม่ควร Update UI ถี่เกินไป เพราะการวาด UI นั้น กิน CPU และเปลืองแบตครับ แล้วยังทำให้มันช้าลงอีก แถมเรายังไปสร้าง Delegate หรือ Anonymous Method ใหม่ทุกครั้งภายใน Loop ด้วย คงจะเปลืองน่าดู ดังนั้น เพื่อความสบายใจ ควรจะเขียนแบบนี้ครับ

private void DoHard()
{
    int i = 0;
    ThreadStart t = delegate { this.progressBar1.Value = i; };

    for (i = 0; i < 10000; i++)
    {
        double d = Math.Sqrt(Math.Pow(0.02 * 0.3 * DateTime.Now.Ticks, i));
        if ( i % 1000 == 0 ) this.BeginInvoke(t);
    }
    this.BeginInvoke(t);
}

ก็คือ เราเอา i ไปไว้ด้านนอก ให้ Anonymous Method สามารถเรียกได้ และเพิ่ม Logic เล็กน้อยว่า ให้ Update ทุก 1,000 ครั้ง นั่นคือ เราจะ Update Progress Bar แค่ 10 ครั้งเท่านั้น และ Update ครั้งสุดท้าย เพื่อเติม ProgressBar ให้เต็ม ลองรันดู จะพบว่าเราสามารถจบ Loop ได้เร็วขึ้นเยอะมากเลยทีเดียว (ผมยังไม่เคยตรวจว่า โค๊ดของ exe ที่ได้ออกมามันต่างกันหรือเปล่าเหมือนกันนะครับ ระหว่างการเอา Anonymous Method ไว้ข้างใน กะไว้ข้างนอกเนี่ย)

และเพื่อความสมบูรณ์แบบ อย่าลืมว่า ผู้ใช้อาจจะเลือกที่จะปิดฟอร์มของเรา ก่อนที่ Thread เบื้องหลังจะทำงานเสร็จ เราจึงควรใช้ Join() เพื่อรอให้ Thread ทำงานจนเสร็จก่อน ถึงค่อยปิดครับ เช่น

private Thread _Work1;
public Form1()
{
    ...
    _Work1 = new Thread(new ThreadStart(this.DoHard));
}

private void DoHard() { ... }

private void button1_Click(object sender, EventArgs e)
{
    ...
    _Work1.Start();
}

protected override void OnClosed(EventArgs e)
{
    _Work1.Join();
    base.OnClosed(e);
}

จะเห็นว่า ผมทำการ override ฟังก์ชั่น OnClosed เพื่อทำการรอให้ Thread จบการทำงานก่อน จึงค่อยยิง Event Closed ออกไป ซึ่งหมายถึงว่า เราได้ Closed จริงๆ แล้ว

 

สำหรับคราวนี้ พอก่อนละครับ Smile

ร่วมให้กำลังใจนักเขียน

อ่านแล้วชอบใจ อยากให้กำลังใจกับผู้แต่งบทความนี้ ขอเชิญร่วมให้กำลังใจผ่าน Paysbuy/Paypal นะครับ ปลอดภัยเพราะทำงานผ่าน SSL และไม่มีค่าใช้จ่ายเพิ่มเติมครับ เว็บเราให้นักเขียน 100% ครับ

Comment ระบบเก่า

 

Caos said:

แอบจับผิดบรรทัดรองสุดท้าย

ไม่ยากครับ ไม่ใช่ไม่อยาก อิอิ

April 25, 2008 3:20 PM
 

nantcom said:

อ่านๆ ดูแล้ว ไม่ถูกใจ เลยแอบแก้ด้วยเลย เหอๆ

ทีหลังพิมพ์ผิดเยอะๆ ดีกว่าอะ จะได้มีคนมาเม้น เงียบเหงาจัง :'(

April 25, 2008 6:40 PM
 

nantcom said:

ลืมบอกไปว่า ใช้กับ Windows form บน Desktop ได้นะครับ :) ถ้าใช้ VB.NET ก้อแค่แปลงโค๊ดตามเลยได้ครับ ใช้วิธีเดียวกัน

May 5, 2008 11:57 PM
 

Exia said:

ขอบคุณมากคับ มีประโยชน์มากเลย อ่าน textbook แล้ว งง ๆ เรื่องนี้อยู่พอดี

May 9, 2008 2:21 PM
 

nantcom said:

ขอบคุณค๊าบ

May 9, 2008 6:26 PM
 

T said:

ขอบคุณมากๆ ค่ะ (-/\-)

May 13, 2008 10:45 AM
 

monixe said:

ขอบคุณคร้าบ

ผมมือใหม่ กำลังจะขับออกถนนใหญ่...

- -"

May 22, 2008 7:50 PM
 

robot said:

้เพิ่งได้มาอ่าน ดีจังคับ ปกติผมก็ใช้แต่ doevent() ตลอด เพราะไม่รู้จะใช้อ่ะไร ขอบคุณครับ

June 9, 2008 12:05 PM
 

อ่าน said:

LINQ นั้น ย่อมาจาก Language Integrated Query หรือแปลเป็นไทย ก็ &quot;การทำ Query แบบฝังในภาษา&quot; ซ

July 1, 2008 9:35 AM
 

nantcom said:

LINQ นั้น ย่อมาจาก Language Integrated Query หรือแปลเป็นไทย ก็ &quot;การทำ Query แบบฝังในภาษา&quot; ซ

July 1, 2008 9:39 AM
 

Neng said:

very good.

October 14, 2008 8:26 PM
 

pandula said:

มีประโยชน์

March 30, 2009 11:03 AM
 

penpencool said:

+1 เยี่ยมมากครับได้ความรู้เยอะเลย

August 19, 2009 7:08 PM
 

THXiLL said:

ไปดูเพิ่มเติมที่กระทู้นี้ด้วยนะครับ จะเข้าใจมากขึ้น coresharp.net/.../1162.aspx

December 30, 2009 1:43 AM
 

GOWLENG said:

ขอบคุณสำหรับความรู้ดีๆคับ

January 26, 2010 10:42 AM
 

EvilSteP said:

ชัดเจน ขอบคุณครับ

November 3, 2010 10:24 AM
 

weerapol said:

ขอบคุณมากครับ ติดตามผลงานมานานแล้ว

อยากทราบวิธีการ Debug เข้าไปใน webservice จาก Mobile Emulator ครับผมหาจนหนื่อยแล้ว

            ตอนนี้ผมใช้วิธี Debug ผ่านตัวแอ Application Solution แทนคือสร้างแอพแล้วรัน เมื่อเขียนเรียบร้อยค่อปปี้มาใส่ Mobile Solution

June 24, 2011 11:58 PM
 

dong said:

ขอบคุณมากครับ ติดตามผลงานมานานแล้ว

อยากทราบวิธีการ Debug เข้าไปใน webservice จาก Mobile Emulator ครับผมหาจนหนื่อยแล้ว

            ตอนนี้ผมใช้วิธี Debug ผ่านตัวแอ Application Solution แทนคือสร้างแอพแล้วรัน เมื่อเขียนเรียบร้อยค่อปปี้มาใส่ Mobile Solution

June 24, 2011 11:59 PM
 

Bee said:

มาจากปัญญาที่หลายๆคนพบเจอ

ได้ประโยชน์มากๆเลยครับ

ขอบคุณครับ

August 4, 2011 11:01 AM
(required)  
(optional)
(required)  
Add

DisQUS Comment (ยังเอ๋อๆ อยู่)

blog comments powered by Disqus