这将是一个很长的答案,其中包括我所有问题的答案以及有关使用方法的建议。
这个答案也还没有完成,但是在word已经有5页之后,我想我现在就发布第一部分。
在运行超过 2160 个基准测试、比较和分析收集的数据后,我非常确定我可以回答自己的问题,并提供有关如何获得 StorageFile(和isolatedStorageFile)最佳性能的更多见解
(有关原始结果和所有基准方法,请参阅问题)
我们看第一个问题:
Why is await StreamReader.ReadToEndAsync()
始终较慢
每个基准都比非异步方法StreamReader.ReadToEnd()
?
Neil Turner 在评论中写道:“循环等待会导致轻微的
性能。由于上下文不断来回切换而受到打击”
我预计性能会受到轻微影响,但我们都认为这不会导致每个等待基准测试都有如此大的下降。
让我们分析一下循环中的等待对性能的影响。
为此,我们首先比较基准 b1 和 b5 的结果(b2 作为不相关的最佳情况比较),这里是两种方法的重要部分:
//b1
for (int i = 0; i < filepaths.Count; i++)
{
StorageFile f = await data.GetFileAsync(filepaths[i]);
using (var stream = await f.OpenStreamForReadAsync())
{
using (StreamReader r = new StreamReader(stream))
{
filecontent = await r.ReadToEndAsync();
}
}
}
//b5
for (int i = 0; i < filepaths.Count; i++)
{
StorageFile f = await data.GetFileAsync(filepaths[i]);
using (var stream = await f.OpenStreamForReadAsync())
{
using (StreamReader r = new StreamReader(stream))
{
filecontent = r.ReadToEnd();
}
}
}
基准测试结果:
50 个文件,100kb:
B1:2651毫秒
B5:1553毫秒
B2: 147
200 个文件,1kb
B1:9984ms
B5: 6572
B2: 87
在这两种情况下,B5 大约花费 B1 所花费时间的 2/3 左右,循环中只有 2 个等待,而 B1 中有 3 个等待。看起来 b1 和 b5 的实际加载可能与 b2 大致相同,只有等待导致性能大幅下降(可能是因为上下文切换)(假设 1)。
让我们尝试计算一次上下文切换需要多长时间(使用 b1),然后检查假设 1 是否正确。
有 50 个文件和 3 个等待,我们有 150 次上下文切换:一次上下文切换需要 (2651ms-147ms)/150 = 16.7ms。我们可以证实这一点吗? :
B5,50 个文件:16.7ms * 50 * 2 = 1670ms + 147ms = 1817ms 与基准测试结果:1553ms
B1、200个文件:16.7ms * 200 * 3 = 10020ms + 87ms = 10107ms vs 9984ms
B5,200个文件:16.7ms * 200 * 2 = 6680ms + 87ms = 6767ms vs 6572ms
看起来很有希望,只有相对较小的差异,这可能归因于基准结果中的误差幅度。
基准(等待、文件):计算与基准结果
B7(1 个等待,50 个文件):16.7ms*50 + 147= 982ms 与 899ms
B7(1 个等待,200 个文件):16.7*200+87 = 3427ms 与 3354ms
B12(1 个等待,50 个文件):982 毫秒 vs 897 毫秒
B12(1 个等待,200 个文件):3427 毫秒 vs 3348 毫秒
B9(3 个等待,50 个文件):2652 毫秒 vs 2526 毫秒
B9(3 个等待,200 个文件):10107ms 与 10014ms
我认为根据这个结果可以肯定地说,一次上下文切换大约需要 16.7 毫秒(至少在一个循环中)。
弄清楚这一点后,一些基准测试结果就更有意义了。在具有 3 个等待的基准测试中,我们大多数情况下看到不同文件大小(1、20、100)的结果仅存在 0.1% 的差异。这大约是我们在参考基准 b2 中可以观察到的绝对差异。
结论:循环中的等待真的很糟糕(如果循环是在 ui 线程中执行的,但我稍后会谈到)
关于问题 2
使用StorageFile打开文件时似乎有很大的开销,
但仅当它在 UI 线程中打开时。 (为什么?)
让我们看看基准 10 和 19:
//b10
for (int i = 0; i < filepaths.Count; i++)
{
using (var stream = new IsolatedStorageFileStream("/benchmarks/samplefiles/" + filepaths[i], FileMode.Open, store))
{
using (StreamReader r = new StreamReader(stream))
{
filecontent = await Task.Factory.StartNew<String>(() => { return r.ReadToEnd(); });
}
}
}
//b19
await await Task.Factory.StartNew(async () =>
{
for (int i = 0; i < filepaths.Count; i++)
{
using (var stream = new IsolatedStorageFileStream("/benchmarks/samplefiles/" + filepaths[i], FileMode.Open, store))
{
using (StreamReader r = new StreamReader(stream))
{
filecontent = await Task.Factory.StartNew<String>(() => { return r.ReadToEnd(); });
}
}
}
});
基准(1kb、20kb、100kb、1mb)(以毫秒为单位):
10:(846、865、916、1564)
19:(35、57、166、1438)
在基准测试 10 中,我们再次看到上下文切换对性能造成了巨大影响。然而,当我们在不同的线程中执行 for 循环 (b19) 时,我们获得的性能几乎与参考基准 2(Ui 阻塞isolatedStorageFile)相同。理论上仍然应该存在上下文切换(至少据我所知)。我怀疑编译器在没有上下文切换的情况下优化了代码。
事实上,我们获得了与基准测试 20 几乎相同的性能,它与基准测试 10 基本相同,但使用了ConfigureAwait(false):
filecontent = await Task.Factory.StartNew<String>(() => { return r.ReadToEnd(); }).ConfigureAwait(false);
20:(36、55、168、1435)
这似乎不仅适用于新任务,而且适用于每个异步方法(至少对于我测试的所有方法)
所以这个问题的答案是答案一和我们刚刚发现的内容的结合:
很大的开销是由于上下文切换造成的,但是在不同的线程中,要么不发生上下文切换,要么不存在由它们引起的开销。 (当然,这不仅适用于问题中所要求的打开文件,而且适用于每个异步方法)
问题3
问题 3 无法真正得到完全回答,在特定条件下总是有可能更快一点的方法,但我们至少可以告诉我们永远不应该使用某些方法,并从数据中找到最常见情况的最佳解决方案我收集:
我们先来看看StreamReader.ReadToEndAsync
和替代方案。为此,我们可以比较基准 7 和基准 10
它们只有一行不同:
b7:
filecontent = await r.ReadToEndAsync();
b10:
filecontent = await Task.Factory.StartNew<String>(() => { return r.ReadToEnd(); });
你可能认为他们的表现会相似好坏,但你错了(至少在某些情况下)。
当我第一次想到做这个测试时,我想ReadToEndAsync()
将以这种方式实施。
基准:
b7:(848、853、899、3386)
b10:(846、865、916、1564)
我们可以清楚地看到,在大部分时间都花在读取文件上的情况下,第二种方法要快得多。
我的建议:
不要使用ReadToEndAsync()
但给自己写一个像这样的扩展方法:
public static async Task<String> ReadToEndAsyncThread(this StreamReader reader)
{
return await Task.Factory.StartNew<String>(() => { return reader.ReadToEnd(); });
}
始终使用这个而不是ReadToEndAsync()
.
在比较基准测试 8 和 19 时(即基准测试 7 和 10,for 循环在不同的线程中执行),您可以更清楚地看到这一点:
b8:(55、103、360、3252)
b19:(35、57、166、1438)
b6: (35, 55, 163, 1374)
在这两种情况下,上下文切换都没有开销,您可以清楚地看到,性能ReadToEndAsync()
绝对是可怕的。 (基准 6 也几乎与 8 和 19 相同,但filecontent = r.ReadToEnd();
。还可以扩展到 10 个 10mb 的文件)
如果我们将其与我们的参考 ui 阻塞方法进行比较:
b2:(21、44、147、1365)
我们可以看到,基准测试 6 和 19 都非常接近相同的性能,且不会阻塞 ui 线程。我们可以进一步提高性能吗?是的,但只有少量并行加载:
b14:(36、45、133、1074)
b16:(31、52、141、1086)
但是,如果您查看这些方法,您会发现它们不是很漂亮,并且在必须加载某些内容的任何地方编写都是糟糕的设计。为此我写了这个方法ReadFile(string filepath)
它可用于单个文件、具有 1 个等待的正常循环和具有并行加载的循环。这应该能提供非常好的性能,并产生易于重用和维护的代码:
public async Task<String> ReadFile(String filepath)
{
return await await Task.Factory.StartNew<Task<String>>(async () =>
{
String filec = "";
using (var store = IsolatedStorageFile.GetUserStoreForApplication())
{
using (var stream = new IsolatedStorageFileStream(filepath, FileMode.Open, store))
{
using (StreamReader r = new StreamReader(stream))
{
filec = await r.ReadToEndAsyncThread();
}
}
}
return filec;
});
}
以下是一些基准测试(与基准测试 16 相比)(对于此基准测试,我进行了单独的基准测试运行,其中我从每种方法 100 次运行中获取了中值(而非平均)时间):
b16:(16、32、122、1197)
b22:(59、81、219、1516)
b23:(50、48、160、1015)
b24:(34、50、87、1002)
(所有这些方法的中位数非常接近平均值,平均值有时慢一点,有时快一点。数据应该具有可比性)
(请注意,尽管这些值是 100 次运行的中位数,但 0-100 毫秒范围内的数据实际上并不具有可比性。例如,在前 100 次运行中,基准测试 24 的中位数为 1002 毫秒,在后 100 次运行中,899 毫秒。)
基准 22 与基准 19 具有可比性。基准 23 和 24 与基准 14 和 16 具有可比性。
好的,现在这应该是当isolatedStorageFile可用时读取文件的最佳方法之一。
对于只有 StorageFile 可用的情况(与 Windows 8 应用程序共享代码),我将为 StorageFile 添加类似的分析。
因为我对 StorageFile 在 Windows 8 上的执行方式感兴趣,所以我可能也会在我的 Windows 8 计算机上测试所有 StorageFile 方法。 (尽管我可能不会写分析)