C#警惕匿名方法造成的变量共享实例分析

 更新时间:2020年6月25日 11:26  点击:1315

本文实例讲述了C#警惕匿名方法造成的变量共享。分享给大家供大家参考,具体如下:

匿名方法

匿名方法是.NET 2.0中引入的高级特性,“匿名”二字说明它可以把实现内联地写在一个方法中,从而形成一个委托对象,而不用有明确地方法名,例如:

static void Test()
{
  Action<string> action = delegate(string value)
  {
    Console.WriteLine(value);
  };
  action("Hello World");
}

但是匿名方法的关键并不仅于“匿名”二字。其最强大的特性就在于匿名方法形成了一个闭包,它可以作为参数传递到另一个方法中去,但同时也能访问方法的局部变量和当前类中的其它成员。例如:

class TestClass
{
  private void Print(string message)
  {
    Console.WriteLine(message);
  }
  public void Test()
  {
    string[] messages = new string[] { "Hello", "World" };
    int index = 0;
    Action<string> action = (m) =>
    {
      this.Print((index++) + ". " + m);
    };
    Array.ForEach(messages, action);
    Console.WriteLine("index = " + index);
  }
}

如上所示,在TestClass的Test方法中,action委托调用了同在TestClass类中的私有方法Print,并对Test方法中的局部变量index进行了读写。在加上C# 3.0中Lambda表达式的新特性,匿名方法的使用得到了极大的推广。不过,如果使用不当,匿名方法也容易造成难以发现的问题。

问题案例

某位兄弟最近在一个简单的数据导入程序,主要工作是从文本文件中读取数据,进行分析和重组,然后写入数据库。其逻辑大致如下:

static void Process()
{
  List<Item> batchItems = new List<Item>();
  foreach (var item in ...)
  {
    batchItems.Add(item);
    if (batchItems.Count > 1000)
    {
      DataContext db = new DataContext();
      db.Items.InsertAllOnSubmit(batchItems);
      db.SubmitChanges();
      batchItems = new List<Item>();
    }
  }
}

每次从数据源中读取数据后,添加到batchItems列表中,当batchItems满1000条时便进行一次提交。这段代码功能运行正常,可惜时间卡在了数据库提交上。数据的获取和处理很快,但是提交一次就要花较长时间。于是想想,数据提交和数据处理不会有资源上的冲突,那么就把数据提交放在另外一个线程上进行处理吧!于是,使用ThreadPool来改写代码:

static void Process()
{ 
  List<Item> batchItems = new List<Item>();
  foreach (var item in ...)
  {
    batchItems.Add(item);
    if (batchItems.Count > 1000)
    {
      ThreadPool.QueueUserWorkItem((o) =>
      {
        DataContext db = new DataContext();
        db.Items.InsertAllOnSubmit(batchItems);
        db.SubmitChanges();
      });
      batchItems = new List<Item>();
    }
  }
}

现在,我们将数据提交操作交给ThreadPoll执行,当线程池中有额外线程时,就会发起数据提交操作。而数据提交操作不会阻塞数据处理,因此按照那位兄弟的意图,数据会不断进行处理,最后只要等待所有数据库提交完成就可以了。思路很好,可惜运行时发现,原本(不利用多线程时)运行正常的代码,如今会“莫名其妙”地抛出异常。更为奇怪的是,数据库中的数据出现了丢失的情况:处理了并“提交”了一百万条数据,但是数据库里却少了一部分。于是对着代码左看右看,百思不得其解。

您看出问题原因来了吗?

分析原因

要发现问题所在,我们必须了解匿名方法在.NET环境中的实现方式。

.NET中本没有什么“匿名方法”,也没有类似的新特性。“匿名方法”完全是由编译器施展的魔法,它会将匿名方法中需要访问的所有成员一起包含在闭包中,确保所有的成员调用都符合.NET标准。例如在文章第一节中的第2个示例,实际上由编译器处理之后就变成了如下的样子(自然字段名经过“友好化”处理):

class TestClass
{
  ...
  private sealed class AutoGeneratedHelperClass
  {
    public TestClass m_testClassInstance;
    public int m_index;
    public void Action(string m)
    {
      this.m_index++;
      this.m_testClassInstance.Print(m);
    }
  }
  public void TestAfterCompiled()
  {
    AutoGeneratedHelperClass helper = new AutoGeneratedHelperClass();
    helper.m_testClassInstance = this;
    helper.m_index = 0;
    string[] messages = new string[] { "Hello", "World" };
    Action<string> action = new Action<string>(helper.Action);
    Array.ForEach(messages, action);
    Console.WriteLine(helper.m_index);
  }
}

由此就可以看出编译器是如何实现一个闭包的:

编译器自动生成一个私有的内部辅助类,并将其设为sealed,这个类的实例将成为一个闭包对象。

如果匿名方法需要访问方法的参数或局部变量,那么该参数或局部变量将“升级”成为辅助类中的公有Field字段。

如果匿名方法需要访问类中的其它方法,那么辅助类中将保存类的当前实例。

值得一提的是,在实际情况下以上三点理论都皆可能不满足。在某些特别简单的情况下(例如匿名方法中完全不涉及局部变量和其他方法),编译器只会简单生成一个静态的方法来构造一个委托实例,因为这样可以获得更好的性能。

对于之前的案例,我们现在也将它进行一番改写,这样便可“避免”使用匿名对象,也可以清楚地展现出问题原因:

private class AutoGeneratedClass
{
  public List<Item> m_batchItems;
  public void WaitCallback(object o)
  {
    DataContext db = new DataContext();
    db.Items.InsertAllOnSubmit(this.m_batchItems);
    db.SubmitChanges();
  }
}
static void Process()
{ 
  var helper = new AutoGeneratedClass();
  helper.m_batchItems = new List<Item>();
  foreach (var item in ...)
  {
    helper.m_batchItems.Add(item);
    if (helper.m_batchItems.Count > 1000)
    {
      ThreadPool.QueueUserWorkItem(helper.WaitCallback);
      helper.m_batchItems = new List<Item>();
    }
  }
}

编译器会自动生成一个AutoGeneratedClass类,并且在Process方法中使用这个类的实例来代替原来的batchItems局部变量。同样,交给ThreadPool的委托对象也从匿名方法变成了AutoGeneratedClass实例的公有方法。因此线程池每次调用的便是该实例的WaitCallback方法。

现在问题应该一目了然了吧?每次把委托交给线程池之后,线程池并不会立即执行,而会保留到合适的时间再进行。而WaitCallback方法在执行时,它会读取m_batchItems这个Field字段“当前”所引用的对象。而与此同时,Process方法已经“抛弃”了原本我们要提交的数据,因此会引起提交到数据库中数据的丢失。同时,在准备每批次数据的过程中,很有可能会发起两次数据提交,两个线程提交同样一批Item时,就抛出了所谓“莫名其妙”的异常。

解决问题

找到了问题所在,解决起来自然轻而易举:

private class WrapperClass
{
  private List<Item> m_items;
  public WrapperClass(List<Item> items)
  {
    this.m_items = items;
  }
  public void WaitCallback(object o)
  {
    DataContext db = new DataContext();
    db.Items.InsertAllOnSubmit(this.m_items);
    db.SubmitChanges();
  }
}
static void Process()
{
  List<Item> batchItems = new List<Item>();
  foreach (var item in ...)
  {
    batchItems.Add(item);
    if (batchItems.Count > 1000)
    {
      ThreadPool.QueueUserWorkItem(
        new WrapperClass(batchItems).WaitCallback);
      batchItems = new List<Item>();
    }
  }
}

这里我们明确地准备一个封装类,用它来保留我们需要提交的数据。而每次提交时则使用保留好的数据,自然不会发生不该有的“数据共享”,从而避免了错误的发生1。

总结

匿名方法是强大的,但是也会造成一些令人难以察觉的陷阱。对于使用匿名方法创建的委托,如果不会立即同步执行,并且其中使用了方法的局部变量,那么您就需要对其留个心眼了。因为此时“局部变量”事实上已经由编译器转变成一个自动类的实例上的Field字段,而这个字段将被当前方法和委托对象共享。如果您在创建了委托对象之后还会修改共享的“局部变量”,那么请再三确认这样做符合您的意图,而不会造成问题。

此类问题也不光会出现在匿名方法中。如果您使用Lambda表达式创建了一个表达式树,其中也用到了一个“局部变量”,那么表达式树在解析或执行时同样也会获取“当前”的值,而不是创建表达式树时的值。

这也是为什么Java中的内联写法——匿名类——如果要共享方法内的“局部变量”,则必须将变量使用final关键字来修饰:这样这个变量只能在声明时赋值,避免了后续的“修改”可能会造成的“古怪问题”。

希望本文所述对大家C#程序设计有所帮助。

[!--infotagslink--]

相关文章

  • C#实现简单的登录界面

    我们在使用C#做项目的时候,基本上都需要制作登录界面,那么今天我们就来一步步看看,如果简单的实现登录界面呢,本文给出2个例子,由简入难,希望大家能够喜欢。...2020-06-25
  • 浅谈C# 字段和属性

    这篇文章主要介绍了C# 字段和属性的的相关资料,文中示例代码非常详细,供大家参考和学习,感兴趣的朋友可以了解下...2020-11-03
  • C#中截取字符串的的基本方法详解

    这篇文章主要介绍了C#中截取字符串的的基本方法,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧...2020-11-03
  • C#连接SQL数据库和查询数据功能的操作技巧

    本文给大家分享C#连接SQL数据库和查询数据功能的操作技巧,本文通过图文并茂的形式给大家介绍的非常详细,需要的朋友参考下吧...2021-05-17
  • C#实现简单的Http请求实例

    这篇文章主要介绍了C#实现简单的Http请求的方法,以实例形式较为详细的分析了C#实现Http请求的具体方法,需要的朋友可以参考下...2020-06-25
  • C#中new的几种用法详解

    本文主要介绍了C#中new的几种用法,具有很好的参考价值,下面跟着小编一起来看下吧...2020-06-25
  • 使用Visual Studio2019创建C#项目(窗体应用程序、控制台应用程序、Web应用程序)

    这篇文章主要介绍了使用Visual Studio2019创建C#项目(窗体应用程序、控制台应用程序、Web应用程序),小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧...2020-06-25
  • C#开发Windows窗体应用程序的简单操作步骤

    这篇文章主要介绍了C#开发Windows窗体应用程序的简单操作步骤,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧...2021-04-12
  • C#从数据库读取图片并保存的两种方法

    这篇文章主要介绍了C#从数据库读取图片并保存的方法,帮助大家更好的理解和使用c#,感兴趣的朋友可以了解下...2021-01-16
  • C#和JavaScript实现交互的方法

    最近做一个小项目不可避免的需要前端脚本与后台进行交互。由于是在asp.net中实现,故问题演化成asp.net中jiavascript与后台c#如何进行交互。...2020-06-25
  • 经典实例讲解C#递归算法

    这篇文章主要用实例讲解C#递归算法的概念以及用法,文中代码非常详细,帮助大家更好的参考和学习,感兴趣的朋友可以了解下...2020-06-25
  • C++调用C#的DLL程序实现方法

    本文通过例子,讲述了C++调用C#的DLL程序的方法,作出了以下总结,下面就让我们一起来学习吧。...2020-06-25
  • 轻松学习C#的基础入门

    轻松学习C#的基础入门,了解C#最基本的知识点,C#是一种简洁的,类型安全的一种完全面向对象的开发语言,是Microsoft专门基于.NET Framework平台开发的而量身定做的高级程序设计语言,需要的朋友可以参考下...2020-06-25
  • C#变量命名规则小结

    本文主要介绍了C#变量命名规则小结,文中介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下...2021-09-09
  • c#中(&&,||)与(&,|)的区别详解

    这篇文章主要介绍了c#中(&&,||)与(&,|)的区别详解,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧...2020-06-25
  • C# 中如何取绝对值函数

    本文主要介绍了C# 中取绝对值的函数。具有很好的参考价值。下面跟着小编一起来看下吧...2020-06-25
  • C#绘制曲线图的方法

    这篇文章主要介绍了C#绘制曲线图的方法,以完整实例形式较为详细的分析了C#进行曲线绘制的具体步骤与相关技巧,具有一定参考借鉴价值,需要的朋友可以参考下...2020-06-25
  • c#自带缓存使用方法 c#移除清理缓存

    这篇文章主要介绍了c#自带缓存使用方法,包括获取数据缓存、设置数据缓存、移除指定数据缓存等方法,需要的朋友可以参考下...2020-06-25
  • C#学习笔记- 随机函数Random()的用法详解

    下面小编就为大家带来一篇C#学习笔记- 随机函数Random()的用法详解。小编觉得挺不错的,现在就分享给大家,也给大家做个参考。一起跟随小编过来看看吧...2020-06-25
  • C#中list用法实例

    这篇文章主要介绍了C#中list用法,结合实例形式分析了C#中list排序、运算、转换等常见操作技巧,具有一定参考借鉴价值,需要的朋友可以参考下...2020-06-25