代码改变世界

C# 线程手册 第二章 .NET 中的线程 线程陷阱

2012-01-18 17:15  DanielWise  阅读(3071)  评论(0编辑  收藏  举报

我们已经讨论过在程序中使用线程的两个主要优势。然而,在另外一些情况下使用线程会是一个糟糕的决定。很明显,我们并不打算列出一个完整的关于在何时创建新线程的列表,而是告诉你一个糟糕的使用线程的决定都包含哪些内容。主要有两种情况:第一种是当保证程序的执行顺序非常重要时,第二种看起来更像是一个代码中的错误-在一个循环中创建新线程。

再次探访线程的执行程序

回顾一下我们在本章之前部分用来演示从一个线程跳到另外一个线程的随机执行顺序代码。看起来好像一个线程会执行并在控制台上打出10条记录,然后下一个线程将显示15条记录,之后再返回到第一个线程显示8条记录。决定是否使用线程时会经常遇到一个假定自己很确定地知道在线程指定的时间片中将要执行多少代码的问题。

下面的例子描述了这个问题。看起来线程t1会先完成因为它先开始,但是这个想法确实一个大问题。创建一个控制台应用程序ExecuteOrder, 编译然后运行这个例子多次-每次你都会得到不同结果。

/*************************************
/* Copyright (c) 2012 Daniel Dong
 * 
 * Author:oDaniel Dong
 * Blog:o  www.cnblogs.com/danielWise
 * Email:o guofoo@163.com
 * 
 */

using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;

namespace SimpleThread
{
    class ExecutionOrder
    {
        static Thread t1;
        static Thread t2;

        public static void WriteFinished(string threadName)
        {
            switch (threadName)
            {
                case "T1":
                    Console.WriteLine();
                    Console.WriteLine("T1 Finished");
                    break;
                case "T2":
                    Console.WriteLine();
                    Console.WriteLine("T2 Finished");
                    break;
                default:
                    break;
            }
        }

        public static void Main()
        {
            t1 = new Thread(new ThreadStart(Increment));
            t2 = new Thread(new ThreadStart(Increment));
            t1.Name = "T1";
            t2.Name = "T2";
            t1.Start();
            t2.Start();
            Console.ReadLine();
        }

        public static void Increment()
        {
            for (long i = 1; i <= 1000000; i++)
            {
                if (i % 100000 == 0)
                {
                    Console.Write(" {" + Thread.CurrentThread.Name + "} ");
                }
                WriteFinished(Thread.CurrentThread.Name);
            }
        }
    }
}

有些时候t1先完成,然后t2会再执行一些代码并完成。有些时候t2先完成然后t1开始执行。问题是你不能假定哪个线程先开始那么这个线程就先完成。这本书的后面我们将讨论如何同步线程以使其可以按顺序执行。然而,要注意默认情况下线程之间是不会同步执行的。

不知道线程何时结束并不是乱序执行的唯一问题。下个例子将演示乱序执行的线程是如何产生极大的影响的,ExecutionOrder2:

/*************************************
/* Copyright (c) 2012 Daniel Dong
 * 
 * Author:oDaniel Dong
 * Blog:o  www.cnblogs.com/danielWise
 * Email:o guofoo@163.com
 * 
 */

using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;

namespace SimpleThread
{
    class ExecutionOrder2
    {
        static Thread t1;
        static Thread t2;
        static int iIncr;

        public static void WriteFinished(string threadName)
        {
            switch (threadName)
            {
                case "T1":
                    Console.WriteLine();
                    Console.WriteLine("T1 Finished: iIncr = " + iIncr.ToString());
                    break;
                case "T2":
                    Console.WriteLine();
                    Console.WriteLine("T2 Finished: iIncr = " + iIncr.ToString());
                    break;
                default:
                    break;
            }
        }

        public static void Main()
        {
            t1 = new Thread(new ThreadStart(Increment));
            t2 = new Thread(new ThreadStart(Increment));
            t1.Name = "T1";
            t2.Name = "T2";
            t1.Start();
            t2.Start();
            Console.ReadLine();
        }

        public static void Increment()
        {
            for (long i = 1; i <= 1000000; i++)
            {
                if (i % 100000 == 0)
                {
                    Console.Write(" {" + Thread.CurrentThread.Name + "} ");
                }
            }
            iIncr++;
            WriteFinished(Thread.CurrentThread.Name);
        }
    }
}

这个类与ExecuteOrder类似。这次,我们使用一个共享增量计数器iIncr. 我们告诉程序在执行WriteFinished()方法之前要增加这个变量的值。如果执行很多次,你将发现增量计数器的值增加很多次。要记住我们会在后面章节介绍如何同步线程。以上两个例子应该避免使用,因为它们在默认情况下不是按顺序执行。当然也可以使用Join()方法来简单实现同步。线程同步将会在本书稍后章节做深入探讨。

循环里的线程

当一些人发现线程很有趣以后他们会经常犯一个常见错误,那就是在一个循环内创建并使用线程。下面会给出一个描述这类问题的例子,这些例子中的代码通常是由刚接触线程概念的程序员完成的(我也做过,所以我承认自己对线程的了解上还是新人)。这个问题在程序员或是系统管理员身上很常见,因为他们通常希望在发生某个事件时系统自动给他们发送通知。想法不错,但是把生成线程放到一个循环里却会导致很多问题。

/*************************************
/* Copyright (c) 2012 Daniel Dong
 * 
 * Author:oDaniel Dong
 * Blog:o  www.cnblogs.com/danielWise
 * Email:o guofoo@163.com
 * 
 */

using System;
using System.Collections.Generic;
using System.Text;
using System.Net.Mail;
using System.Threading;
using System.Collections;
using System.Net;

namespace SimpleThread
{
    public class LoopingThread
    {
        public delegate void SendMail(string oMessageTo);
        public class MyMail
        {
            public string EmailTo;
            public string EmailFrom;
            public string EmailSubject;
            public string EmailBody;
            public SendMail SendThisMail; //Delegate Instance

            public void Send()
            {
                MailMessage oMail = new MailMessage(EmailFrom, EmailTo, EmailSubject, EmailBody);
                oMail.IsBodyHtml = false;
                //Here we use netease's email, you can change by your situation.
                SmtpClient client = new SmtpClient("smtp.163.com", 25);
                client.Credentials = new NetworkCredential("your email account", "your email password"); 
                client.Send(oMail);
                SendThisMail(EmailTo);
            }
        }

        public static Thread CreateEmail(SendMail oSendEmail,
            string EmailTo, string EmailFrom,
            string EmailBody, string EmailSubject)
        {
            MyMail oMail = new MyMail();
            oMail.EmailFrom = EmailFrom;
            oMail.EmailBody = EmailBody;
            oMail.EmailSubject = EmailSubject;
            oMail.EmailTo = EmailTo;
            oMail.SendThisMail = oSendEmail;

            Thread t = new Thread(new ThreadStart(oMail.Send));
            return t;
        }
    }

    public class Mailer
    {
        public static void MainMethod(string oString)
        {
            Console.WriteLine("Sending Email: " + oString);
        }
    }

    public class DoMain
    {
        static ArrayList al = new ArrayList();

        public static void Main()
        {
            for (int i = 1; i <= 5; i++)
            {
                //Here we use netease's email, you can change by your situation.
                al.Add(i.ToString() + "@163.com");
            }
            SendAllEmail();
        }

        public static void SendAllEmail()
        {
            int loopTo = al.Count - 1;
            for (int i = 0; i <= loopTo; i++)
            {
                Thread t = LoopingThread.CreateEmail(
                    new LoopingThread.SendMail(Mailer.MainMethod),
                    (string)al[i],
                    "guofoo@163.com",
                    "Threading in a loop",
                    "Mail Example");
                t.Start();
                t.Join(Timeout.Infinite);
            }
        }
    }
}

以上代码可能比你想的要复杂的多,这是因为它同时描述了如何使用委托和一系列冗长的类来调用带形参线程。由于线程只可以创建一个没有参数的方法入口所以这样做是必要的。创建一个可以为其他方法设置参数并返回一个线程对象(本章的后续部分将会做更多描述)的代理方法是程序员的责任。调用方法接下来就可以使用返回线程对象的引用来开始执行。

现在让我们来集中看一下SendAllEmail方法。这是我们对ArrayList进行遍历并向代理方法发送参数的地方。我们为每个我们想发送的邮件地址创建一个新线程:

public static void SendAllEmail()
{
    int loopTo = al.Count - 1;
    for (int i = 0; i <= loopTo; i++)
    {
        Thread t = LoopingThread.CreateEmail(
            new LoopingThread.SendMail(Mailer.MainMethod),
            (string)al[i],
            "guofoo@163.com",
            "Threading in a loop",
            "Mail Example");
        t.Start();
        t.Join(Timeout.Infinite);
    }
}

咋一看来,这是个好主意。为什么不在另外一个线程上发送邮件? 现在的方法反而花费我们更多时间,不是吗? 确实是这样,但是问题在于我们现在通过在不同线程之间切换占用了处理器执行时间。当处理器处理完,分配给每个线程的时间片主要花费在对线程本地存储的打包和拆包上。很少的时间被用在执行线程指令上。系统甚至完全锁住了,导致可怜的guofoo@163.com 收不到任何从我们发送的邮件。创建一个单独的线程并在这个线程上进行发送邮件操作可能会更有意义一些。还有,你可以使用有指定数量线程的线程池。在这种情况下,当线程池中的一个线程完成以后,它将让另外一个线程来发送另外一封邮件。

一个常用的编程实践是将工作放到可以被一个服务处理的队列中去。例如,银行可能在网络上有一个可以被其他服务器运行的服务获取到的基于XML的文件。服务可以扫描指定目录下的新文件并立即处理他们。如果一次有多余一个文件,服务可以一个一个处理。在特殊环境下,新文件可能很少被放到指定目录中。基于这个信息,这看起来可能是一个在找到新文件后启动新线程的机会。你可能是正确的,但是考虑一下一旦处理这些文件的服务停了该怎么办? 如果网络发生了问题导致服务在很长时间内无法访问网络目录该怎么处理? 文件可能堆积在文件夹中。当服务最终起来以后或者可以再次访问网络以后,每个文件可能都会在服务端生成一个线程。任何曾经用过这个模型的人都会告诉你在这种情况下服务端会进入瓶颈。

文件模型仅是一个例子。另外一个类似模型可能是使用微软BizTalk 服务器或者微软消息队列(处理队列中消息的一种架构)。所有的这些实现都有相同的基础结构。具体实现在这里不是重点要关注的。这里要说明的问题是如果你的工作被放入到一个队列中而且你认为可以用多线程来处理时,你可能需要考虑使用线程池。

总结

在这一章我们介绍了System.Threading 命名空间并详细地介绍了Thread类。我们也讨论了一些能够帮助我们在决定是否使用多线程时作出正确决定的能力。你必须牢记只要用了线程就会占用系统资源,线程不是免费的!在你使用这些资源之前,分析系统中的哪些东西会影响它们的使用以及如何减少开销。你应该考虑在诸如访问网络共享或者远程数据库等外部资源时使用多线程。也应该考虑在计划执行诸如打印,I/O操作或者后台数据操作等耗时操作时使用多线程。

不论你是什么情况,都要保持使用最少的线程。你要尽量减少处理器的开销,增加把时间片用在处理线程指令的百分比,然后尽量减少程序需要的内存总量。

至此,C# 线程手册 第二章 .NET 中的线程 已经全部介绍完,下一篇将介绍 第三章 使用线程…