C# IEnumerator/yield 结构可能存在问题?

33

背景:我从数据库中获取了一堆字符串,现在想要返回它们。传统上,代码会是这样的:

public List<string> GetStuff(string connectionString)
{
    List<string> categoryList = new List<string>();
    using (SqlConnection sqlConnection = new SqlConnection(connectionString))
    {
        string commandText = "GetStuff";
        using (SqlCommand sqlCommand = new SqlCommand(commandText, sqlConnection))
        {
            sqlCommand.CommandType = CommandType.StoredProcedure;

            sqlConnection.Open();
            SqlDataReader sqlDataReader = sqlCommand.ExecuteReader();
            while (sqlDataReader.Read())
            {
                categoryList.Add(sqlDataReader["myImportantColumn"].ToString());
            }
        }
    }
    return categoryList;
}

但是我想消费者可能会想要遍历这些项目,而且并不在意其他的东西,我不想将自己局限于一个列表,如果我返回 IEnumerable 就比较好/灵活。因此,我考虑使用“yield return”类型的设计来处理这个问题……就像这样:

public IEnumerable<string> GetStuff(string connectionString)
{
    using (SqlConnection sqlConnection = new SqlConnection(connectionString))
    {
        string commandText = "GetStuff";
        using (SqlCommand sqlCommand = new SqlCommand(commandText, sqlConnection))
        {
            sqlCommand.CommandType = CommandType.StoredProcedure;

            sqlConnection.Open();
            SqlDataReader sqlDataReader = sqlCommand.ExecuteReader();
            while (sqlDataReader.Read())
            {
                yield return sqlDataReader["myImportantColumn"].ToString();
            }
        }
    }
}

现在我正在更深入地了解yield(像这样的网站上... msdn似乎没有提到这一点),它显然是一种惰性评估器,保留填充程序的状态,预期有人要求下一个值,然后仅在运行时直到返回下一个值。

在大多数情况下,这似乎很好,但对于数据库调用来说,这听起来有点危险。 作为一个有些牵强的例子,如果有人要求从我从数据库中调用的IEnumerable,并完成其中一半,然后陷入循环......据我所见,我的数据库连接将永远保持打开状态。

如果迭代器没有完成,听起来有些麻烦......我错过了什么吗?


谢谢修改,Jon...这就是我边打字边想的结果。 - Beska
1
只要您的使用者在 IEnumerator 上调用 Dispose,就是安全的。请参见我下面的帖子。 - tofi9
这与主题有些不相关,我不确定它是否在当时是正确的,但对于未来的读者,SqlDataReader 实现了 IDisposable 接口,因此您应该考虑将其包装在 using 语句中(或使用新的 C# 8 using 声明)。 - Marie
11个回答

44

这是一个权衡考虑:您想要立即强制将所有数据存入内存以便释放连接,还是希望从流式传输数据中受益,代价是在那段时间内占用连接?

我认为这个决定应该由调用者来做,因为他们更了解自己想要做什么。如果你使用一个迭代器块编写代码,调用者可以非常容易地将该流式表单转换为完全缓冲的表单:

List<string> stuff = new List<string>(GetStuff(connectionString));

另一方面,如果你自己进行缓冲,那么调用者就无法返回到流模型。

因此,我可能会使用流模型,并在文档中明确说明它的作用,并建议调用者适当地决定。你甚至可以提供一个帮助方法来调用流式版本并将其转换为列表。

当然,如果你不相信调用者能够做出适当的决定,并且你有充分的理由认为他们永远不会真正想要流式传输数据(例如,它永远不会返回太多数据),那么就采用列表方法。无论哪种方式,都要记录下来 - 这可能会影响返回值的使用方式。

当然,处理大量数据的另一种选择是使用批处理 - 这是从最初的问题中思考的另一种不同的方法,但在通常情况下会更具吸引力的流式传输场景中,这也是需要考虑的方法之一。


你提出的选择是正确的,但我认为更应该优先考虑默认不进行流式传输的决策。保留连接或资源会导致可扩展性问题。默认行为应该是合理的,不会引起问题。 - Jason Coyne

11

使用IEnumerable并不总是不安全的。如果你遵循框架调用GetEnumerator的方式(这是大多数人会采取的方式),那么你就是安全的。基本上,你的方法使用的代码越谨慎,你就越安全:

class Program
{
    static void Main(string[] args)
    {
        // safe
        var firstOnly = GetList().First();

        // safe
        foreach (var item in GetList())
        {
            if(item == "2")
                break;
        }

        // safe
        using (var enumerator = GetList().GetEnumerator())
        {
            for (int i = 0; i < 2; i++)
            {
                enumerator.MoveNext();
            }
        }

        // unsafe
        var enumerator2 = GetList().GetEnumerator();

        for (int i = 0; i < 2; i++)
        {
            enumerator2.MoveNext();
        }
    }

    static IEnumerable<string> GetList()
    {
        using (new Test())
        {
            yield return "1";
            yield return "2";
            yield return "3";
        }
    }

}

class Test : IDisposable
{
    public void Dispose()
    {
        Console.WriteLine("dispose called");
    }
}

你是否可以保持数据库连接开启取决于你的架构。如果调用方参与事务处理(并且您的连接已自动注册),那么该连接将被框架保持开启。

yield 的另一个优点是(在使用服务器端游标时),如果您的消费者想要提前退出循环(例如:在第10个项目后),则代码不必从数据库中全部读取数据(例如:1000个项目)。这可以加速查询数据,特别是在使用服务器端游标检索数据的 Oracle 环境中。


3
+1 是有关处理细节的,但我认为这不是问题所在 - 我相信Beska担心调用者循环的某些迭代需要很长时间才能处理完毕,导致数据库连接保持打开状态,而实际上并不需要。 - Jon Skeet
谢谢,已更新我的想法,保持连接开放。 - tofi9

8
您没有错过任何东西。您的示例展示了如何不使用yield return。将项目添加到列表中,关闭连接并返回列表。您的方法签名仍然可以返回IEnumerable。
编辑:话说,Jon有一点是正确的(惊讶!):在性能方面,流式传输有时确实是最好的选择。毕竟,如果我们谈论的是10万(100万?1000万?)行数据,您不希望先全部加载到内存中。

1
是的...我只是强调了它的IEnumerable方面,因为这让我首先想到使用yield。感谢您的答案...很高兴看到我并没有完全走错路。 - Beska
没问题,伙计,很高兴能帮到你。如果这回答了你的问题,请别忘了将其标记为答案,这样它就会从未回答的问题列表中消失。 - Richard Szalay
不,我没有说反话! - Richard Szalay
Beska:我会让它保持开放状态一段时间。特别是如果Marc Gravell看一眼,我相信他会有一个有趣的意见。我会联系他…… - Jon Skeet
啊,现在我明白你为什么这么想了。"(padding)" 是为了填补注释最少需要15个字符的限制而添加的 :) - Richard Szalay
显示剩余4条评论

6
作为旁注 - 请注意,IEnumerable<T>方法本质上就是LINQ提供程序(如LINQ-to-SQL、LINQ-to-Entities)的主要方式。这种方法有优点,正如Jon所说。然而,也存在明显的问题 - 特别是对于我来说,涉及到分离|抽象的组合方面。
我的意思是:
  • 在MVC场景(例如)中,您希望“获取数据”步骤实际上获取数据,以便您可以在控制器而不是视图中测试它是否有效(而无需记住调用.ToList()等)
  • 您不能保证另一个DAL实现将能够流式传输数据(例如,POX/WSE/SOAP调用通常无法流式传输记录);而且您不一定希望行为变得令人困惑地不同(即,在一个实现中迭代时连接仍然打开,在另一个实现中则关闭)
这与我的想法有些关联:Pragmatic LINQ
但我应该强调 - 流式传输肯定是非常理想的时候。这不是一个简单的“总是 vs 从不”的问题...

3

强制迭代器求值的略微更为简洁的方法:

using System.Linq;

//...

var stuff = GetStuff(connectionString).ToList();

1

不,你走对了路...yield会锁住读者...当调用IEnumerable时,你可以通过执行另一个数据库调用来测试它


你可以在连接字符串中启用MARS,从而允许多个打开的SqlDataReaders,但是这种模式仍然存在问题。 - spoulson

1

这只会在调用者滥用 IEnumerable<T> 协议时才会导致问题。正确使用的方法是在不再需要它时调用 Dispose

yield return 生成的实现将 Dispose 调用视为执行任何开放的 finally 块的信号,这样在你的示例中将调用 using 语句中创建的对象的 Dispose

有许多语言特性(特别是 foreach)使得非常容易正确使用 IEnumerable<T>


如果您能提供一些关于如何使用Dispose的文档,特别是在通过yield return关键字实现的枚举器中使用Dispose的情况下,那将非常有帮助。 - jpierson

0

你可以使用一个单独的线程来缓冲数据(例如到队列中),同时也可以通过yield返回数据。当用户请求数据(通过yield返回)时,从队列中移除一个项目。数据也会通过单独的线程不断地添加到队列中。这样,如果用户请求数据足够快,队列就不会很满,你就不必担心内存问题。如果他们没有这么做,那么队列将会填满,这可能并不是那么糟糕。如果你想对内存施加某种限制,你可以强制实施最大队列大小(此时另一个线程将等待删除项目后再添加更多到队列中)。自然地,你需要确保在两个线程之间正确处理资源(即队列)。

作为替代方案,你可以强制用户传递一个布尔值来指示是否应该缓冲数据。如果为true,则数据被缓冲,并尽快关闭连接。如果为false,则数据不被缓冲,数据库连接将保持打开状态,直到用户需要关闭它。有一个布尔参数可以强制用户做出选择,这可以确保他们知道这个问题。


0

我曾经遇到过这个问题。SQL数据库查询不像文件那样容易进行流式处理。相反,只查询你认为需要的部分,并将其作为任何你想要的容器返回(IList<>DataTable等)。在这里,IEnumerable是无法帮助你的。


-1
你可以使用SqlDataAdapter并填充DataTable。像这样:
public IEnumerable<string> GetStuff(string connectionString)
{
    DataTable table = new DataTable();
    using (SqlConnection sqlConnection = new SqlConnection(connectionString))
    {
        string commandText = "GetStuff";
        using (SqlCommand sqlCommand = new SqlCommand(commandText, sqlConnection))
        {
            sqlCommand.CommandType = CommandType.StoredProcedure;
            SqlDataAdapter dataAdapter = new SqlDataAdapter(sqlCommand);
            dataAdapter.Fill(table);
        }

    }
    foreach(DataRow row in table.Rows)
    {
        yield return row["myImportantColumn"].ToString();
    }
}

这种方式可以一次性查询所有内容,并立即关闭连接,同时仍然可以惰性迭代结果。此外,调用此方法的调用者无法将结果转换为List并执行不应该执行的操作。


3
在这个例子中,我不明白“懒惰地迭代结果”的意义是什么。 - mqp
我认为重点在于 OP 不会被绑定在 List<> 上(这就是他最初选择使用 yield 方法的原因),但同时这也不会使数据库连接保持打开状态。 - Andy
无论采用哪种方法,我都不需要绑定到List<>;我可以以任何方式返回IEnumerable<>。我只是在考虑向比List<>更通用的东西转移,这让我想到了yield以及它可能带来的后果。 - Beska
不,只是其他人建议将List作为IEnumerable返回。虽然这可能不会发生,但方法的调用者可以将其强制转换为列表,而如果使用yield,则无法这样做。 - BFree
我猜那是对的,但这不是不去做它的理由。因为你担心调用者的某些奇怪行为(这些行为不会破坏任何东西),所以到处都使用迭代器而没有真正的原因是愚蠢的。 - mqp

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接