diff --git a/src/ScriptEngine/Machine/CodeStat/CodeStatProcessor.cs b/src/ScriptEngine/Machine/CodeStat/CodeStatProcessor.cs index 15be6508f..4e9de3071 100644 --- a/src/ScriptEngine/Machine/CodeStat/CodeStatProcessor.cs +++ b/src/ScriptEngine/Machine/CodeStat/CodeStatProcessor.cs @@ -5,86 +5,137 @@ This Source Code Form is subject to the terms of the at http://mozilla.org/MPL/2.0/. ----------------------------------------------------------*/ +using System; +using System.Collections.Concurrent; using System.Diagnostics; -using System.Collections.Generic; +using System.Linq; +using System.Threading; namespace ScriptEngine.Machine { + /// + /// Сборщик статистики исполнения кода. Его вызывают все потоки, где исполняется код, + /// в том числе фоновые задания: коллекции потокобезопасные, а время каждый поток меряет сам. + /// public class CodeStatProcessor : ICodeStatCollector { - private Dictionary _codeStat = new Dictionary(); - private Dictionary _watchers = new Dictionary(); - private Stopwatch _activeStopwatch = null; - private HashSet _preparedScripts = new HashSet(); + private readonly ConcurrentDictionary _stats = + new ConcurrentDictionary(); + + private readonly ConcurrentDictionary _preparedScripts = + new ConcurrentDictionary(); + + // Точка, которую сейчас исполняет поток: ей идет время до перехода к следующей точке + private readonly ThreadLocal _activeEntry = + new ThreadLocal(() => new ActiveEntry()); + + // Делегат создается один раз: лямбда в вызове GetOrAdd выделяла бы память на каждую строку + private readonly Func _newStat; + + // Отчет идет в порядке регистрации точек + private long _registrationOrder; + + public CodeStatProcessor() + { + _newStat = _ => new EntryStat(Interlocked.Increment(ref _registrationOrder)); + } public bool IsPrepared(string ScriptFileName) { - return _preparedScripts.Contains(ScriptFileName); + return _preparedScripts.ContainsKey(ScriptFileName); } public void MarkEntryReached(CodeStatEntry entry, int count = 1) { - int oldValue = 0; - _codeStat.TryGetValue(entry, out oldValue); - _codeStat[entry] = oldValue + count; - + var stat = _stats.GetOrAdd(entry, _newStat); if (count == 0) - { - if (!_watchers.ContainsKey(entry)) - { - _watchers.Add(entry, new Stopwatch()); - } - } - else - { - _activeStopwatch?.Stop(); - _activeStopwatch = _watchers[entry]; - _activeStopwatch.Start(); - } + return; + + Interlocked.Add(ref stat.Count, count); + SwitchTo(stat); } public void MarkPrepared(string scriptFileName) { - _preparedScripts.Add(scriptFileName); + _preparedScripts.TryAdd(scriptFileName, true); } + /// + /// Снимок статистики. Можно брать, пока код еще исполняется в других потоках. + /// public CodeStatDataCollection GetStatData() { - CodeStatDataCollection data = new CodeStatDataCollection(); - foreach (var item in _codeStat) + var data = new CodeStatDataCollection(); + foreach (var item in _stats.ToArray().OrderBy(x => x.Value.Order)) { if (!IsPrepared(item.Key.ScriptFileName)) { continue; } - data.Add(new CodeStatData(item.Key, _watchers[item.Key].ElapsedMilliseconds, item.Value)); + data.Add(new CodeStatData(item.Key, item.Value.ElapsedMilliseconds, Volatile.Read(ref item.Value.Count))); } - + return data; } + /// + /// Завершает замер времени в текущем потоке + /// public void EndCodeStat() { - _activeStopwatch?.Stop(); + StopCurrentWatch(); + } + + public void StopCurrentWatch() + { + SwitchTo(null); } public void StopWatch(CodeStatEntry entry) { - if (_watchers.ContainsKey(entry)) + if (_stats.TryGetValue(entry, out var stat) && _activeEntry.Value.Stat == stat) { - _watchers[entry].Stop(); + SwitchTo(null); } } public void ResumeWatch(CodeStatEntry entry) { - _activeStopwatch?.Stop(); + _stats.TryGetValue(entry, out var stat); + SwitchTo(stat); + } + + private void SwitchTo(EntryStat next) + { + var active = _activeEntry.Value; + var now = Stopwatch.GetTimestamp(); + active.Stat?.AddElapsed(now - active.Since); + active.Stat = next; + active.Since = now; + } - if (_watchers.ContainsKey(entry)) + private sealed class EntryStat + { + public readonly long Order; + public int Count; + private long _elapsedTicks; + + public EntryStat(long order) { - _activeStopwatch = _watchers[entry]; - _activeStopwatch.Start(); + Order = order; } + + public void AddElapsed(long ticks) => Interlocked.Add(ref _elapsedTicks, ticks); + + // Так же, как Stopwatch.ElapsedMilliseconds + public long ElapsedMilliseconds => + Stopwatch.GetElapsedTime(0, Interlocked.Read(ref _elapsedTicks)).Ticks / TimeSpan.TicksPerMillisecond; + } + + private sealed class ActiveEntry + { + public EntryStat Stat; + public long Since; } } } diff --git a/src/ScriptEngine/Machine/CodeStat/ICodeStatCollector.cs b/src/ScriptEngine/Machine/CodeStat/ICodeStatCollector.cs index 79e093d5a..26a3fdb00 100644 --- a/src/ScriptEngine/Machine/CodeStat/ICodeStatCollector.cs +++ b/src/ScriptEngine/Machine/CodeStat/ICodeStatCollector.cs @@ -15,5 +15,11 @@ public interface ICodeStatCollector void StopWatch(CodeStatEntry entry); void ResumeWatch(CodeStatEntry entry); + + /// + /// Текущий поток закончил исполнять код: время до следующего исполнения на нем + /// не относится к последней достигнутой точке + /// + void StopCurrentWatch(); } } diff --git a/src/ScriptEngine/Machine/StackMachineExecutor.cs b/src/ScriptEngine/Machine/StackMachineExecutor.cs index fb6aa2614..09152f795 100644 --- a/src/ScriptEngine/Machine/StackMachineExecutor.cs +++ b/src/ScriptEngine/Machine/StackMachineExecutor.cs @@ -45,6 +45,10 @@ public void BeforeProcessStart(IBslProcess process) public void AfterProcessExit(IBslProcess process) { + // Поток фонового задания или запроса веб-сервера возвращается в пул, + // и время простоя не должно уйти последней исполненной строке + process.Services.TryResolve()?.StopCurrentWatch(); + var debugger = process.Services.TryResolve(); if (debugger?.IsEnabled != true) return; diff --git a/src/ScriptEngine/Machine/TypeFactory.cs b/src/ScriptEngine/Machine/TypeFactory.cs index 93c2401f6..ff9ee620c 100644 --- a/src/ScriptEngine/Machine/TypeFactory.cs +++ b/src/ScriptEngine/Machine/TypeFactory.cs @@ -7,6 +7,7 @@ This Source Code Form is subject to the terms of the using ScriptEngine.Machine.Contexts; using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -25,7 +26,8 @@ public class TypeFactory : ITypeFactory { private readonly TypeDescriptor _systemType; - private Dictionary _constructorsCache = new Dictionary(); + // Фабрика одна на тип, а объекты создаются из разных потоков + private readonly ConcurrentDictionary _constructorsCache = new ConcurrentDictionary(); private static readonly Refl.MethodInfo CopyMethod = typeof(TypeFactory) .GetMethod(nameof(CaptureVariantArgs), Refl.BindingFlags.Static | Refl.BindingFlags.InvokeMethod | Refl.BindingFlags.NonPublic); diff --git a/src/ScriptEngine/Types/TypeFactoryCache.cs b/src/ScriptEngine/Types/TypeFactoryCache.cs index aba6d3600..bf50ed041 100644 --- a/src/ScriptEngine/Types/TypeFactoryCache.cs +++ b/src/ScriptEngine/Types/TypeFactoryCache.cs @@ -5,7 +5,7 @@ This Source Code Form is subject to the terms of the at http://mozilla.org/MPL/2.0/. ----------------------------------------------------------*/ -using System.Collections.Generic; +using System.Collections.Concurrent; using OneScript.Types; using ScriptEngine.Machine; @@ -13,17 +13,12 @@ namespace ScriptEngine.Types { public class TypeFactoryCache { - private readonly Dictionary _factories = new Dictionary(); + // Объекты создаются из всех потоков, где исполняется код, в том числе из фоновых заданий + private readonly ConcurrentDictionary _factories = new ConcurrentDictionary(); public TypeFactory GetFactoryFor(TypeDescriptor type) { - if (!_factories.TryGetValue(type, out var factory)) - { - factory = new TypeFactory(type); - _factories[type] = factory; - } - - return factory; + return _factories.GetOrAdd(type, t => new TypeFactory(t)); } } } \ No newline at end of file diff --git a/src/Tests/OneScript.Core.Tests/CodeStatProcessorTests.cs b/src/Tests/OneScript.Core.Tests/CodeStatProcessorTests.cs new file mode 100644 index 000000000..a2f80e944 --- /dev/null +++ b/src/Tests/OneScript.Core.Tests/CodeStatProcessorTests.cs @@ -0,0 +1,334 @@ +/*---------------------------------------------------------- +This Source Code Form is subject to the terms of the +Mozilla Public License, v.2.0. If a copy of the MPL +was not distributed with this file, You can obtain one +at http://mozilla.org/MPL/2.0/. +----------------------------------------------------------*/ + +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Linq; +using System.Threading; +using FluentAssertions; +using OneScript.StandardLibrary; +using ScriptEngine.HostedScript; +using ScriptEngine.HostedScript.Extensions; +using ScriptEngine.Hosting; +using ScriptEngine.Machine; +using Xunit; + +namespace OneScript.Core.Tests +{ + public class CodeStatProcessorTests + { + private const string ModuleName = "module.os"; + + [Fact] + public void CountsFromManyThreadsAddUp() + { + const int threadsCount = 16; + const int iterations = 20000; + var collector = new CodeStatProcessor(); + var entries = Enumerable.Range(1, 50).Select(i => new CodeStatEntry(ModuleName, "Метод", i)).ToArray(); + + RunInParallel(threadsCount, () => + { + // Все потоки впервые исполняют один и тот же модуль и регистрируют одни и те же точки + foreach (var entry in entries) + { + collector.MarkEntryReached(entry, count: 0); + } + collector.MarkPrepared(ModuleName); + + for (var i = 0; i < iterations; i++) + { + collector.MarkEntryReached(entries[i % entries.Length]); + } + }); + + collector.EndCodeStat(); + var data = collector.GetStatData(); + + data.Should().HaveCount(entries.Length); + data.Select(x => x.ExecutionCount).Should() + .AllBeEquivalentTo(threadsCount * iterations / entries.Length); + } + + [Fact] + public void StatDataCanBeTakenWhileThreadsAreCounting() + { + var collector = new CodeStatProcessor(); + var stop = 0; + var exceptions = new ConcurrentQueue(); + + var workers = Enumerable.Range(0, 8).Select(t => new Thread(() => + { + try + { + var line = 0; + while (Volatile.Read(ref stop) == 0) + { + // Новые точки появляются, пока уже зарегистрированные считаются + var entry = new CodeStatEntry(ModuleName, "Метод" + t, line++ % 1000); + collector.MarkEntryReached(entry, count: 0); + collector.MarkEntryReached(entry); + } + } + catch (Exception e) + { + exceptions.Enqueue(e); + } + })).ToArray(); + + collector.MarkPrepared(ModuleName); + foreach (var worker in workers) + { + worker.Start(); + } + + try + { + for (var i = 0; i < 200; i++) + { + collector.GetStatData(); + collector.EndCodeStat(); + } + } + finally + { + Volatile.Write(ref stop, 1); + foreach (var worker in workers) + { + worker.Join(); + } + } + + exceptions.Should().BeEmpty(); + collector.GetStatData().Should().HaveCount(8 * 1000); + } + + [Fact] + public void TimeIsAccountedToTheLastReachedEntry() + { + var collector = new CodeStatProcessor(); + var first = new CodeStatEntry(ModuleName, "Метод", 1); + var second = new CodeStatEntry(ModuleName, "Метод", 2); + collector.MarkEntryReached(first, count: 0); + collector.MarkEntryReached(second, count: 0); + collector.MarkPrepared(ModuleName); + + collector.MarkEntryReached(first); + Thread.Sleep(100); + collector.MarkEntryReached(second); + collector.MarkEntryReached(first); + Thread.Sleep(100); + collector.EndCodeStat(); + + var data = collector.GetStatData().ToDictionary(x => x.Entry.LineNumber); + data[1].ExecutionCount.Should().Be(2); + data[1].TimeElapsed.Should().BeGreaterOrEqualTo(190); + data[2].ExecutionCount.Should().Be(1); + data[2].TimeElapsed.Should().BeLessThan(50); + } + + [Fact] + public void StoppedEntryDoesNotAccumulateTime() + { + var collector = new CodeStatProcessor(); + var entry = new CodeStatEntry(ModuleName, "Метод", 1); + collector.MarkEntryReached(entry, count: 0); + collector.MarkPrepared(ModuleName); + + collector.MarkEntryReached(entry); + collector.StopWatch(entry); + Thread.Sleep(100); + collector.EndCodeStat(); + + collector.GetStatData().Single().TimeElapsed.Should().BeLessThan(50); + } + + [Fact] + public void StatDataKeepsRegistrationOrder() + { + var collector = new CodeStatProcessor(); + var entries = Enumerable.Range(1, 100) + .Select(i => new CodeStatEntry("module" + (i % 7) + ".os", "Метод" + (i % 3), i)) + .ToArray(); + + foreach (var entry in entries) + { + collector.MarkEntryReached(entry, count: 0); + collector.MarkPrepared(entry.ScriptFileName); + } + + collector.GetStatData().Select(x => x.Entry).Should().Equal(entries); + } + + [Fact] + public void UnregisteredEntryDoesNotBreakExecution() + { + var collector = new CodeStatProcessor(); + var entry = new CodeStatEntry(ModuleName, "Метод", 1); + + Action act = () => collector.MarkEntryReached(entry); + + act.Should().NotThrow(); + } + + [Fact] + public void BackgroundTasksExecuteSameModulesUnderCodeStat() + { + const int tasksCount = 16; + const int classesCount = 10; + const int repeats = 200; + + var dir = Path.Combine(Path.GetTempPath(), "codestat-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + for (var i = 0; i < classesCount; i++) + { + File.WriteAllText(Path.Combine(dir, $"Класс{i}.os"), ClassModule); + } + + var collector = new CodeStatProcessor(); + var engine = CreateEngine(collector); + + var mainModule = MainModule + .Replace("%КАТАЛОГ%", dir) + .Replace("%ЗАДАНИЙ%", tasksCount.ToString()) + .Replace("%КЛАССОВ%", classesCount.ToString()) + .Replace("%ПОВТОРОВ%", repeats.ToString()); + + RunScript(engine, mainModule); + + collector.EndCodeStat(); + var data = collector.GetStatData(); + for (var i = 0; i < classesCount; i++) + { + var fileName = $"Класс{i}.os"; + // Строка тела цикла в методе Посчитать + data.Single(x => x.Entry.ScriptFileName.EndsWith(fileName) && x.Entry.LineNumber == 5) + .ExecutionCount.Should().Be(tasksCount * repeats, fileName); + } + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void IdleTimeAfterProcessExitIsNotAccounted() + { + var collector = new CodeStatProcessor(); + var engine = CreateEngine(collector); + + // Как поток пула: процесс завершился, поток простаивает, потом исполняет следующий процесс + RunScript(engine, "А = 1;"); + Thread.Sleep(200); + RunScript(engine, "Б = 2;"); + collector.EndCodeStat(); + + collector.GetStatData().Select(x => x.TimeElapsed).Should().OnlyContain(x => x < 100); + } + + // Движок собирается так же, как в oscript с -codestat + private static HostedScriptEngine CreateEngine(ICodeStatCollector collector) + { + var builder = DefaultEngineBuilder.Create() + .SetDefaultOptions() + .UseImports() + .UseDefaultHosting() + .SetupEnvironment(e => e.AddStandardLibrary()); + builder.Services.RegisterSingleton(collector); + var engine = new HostedScriptEngine(builder.Build()); + engine.Initialize(); + return engine; + } + + private static void RunScript(HostedScriptEngine engine, string code) + { + engine.Engine.AttachedScriptsFactory.LoadFromString( + engine.GetCompilerService(), code, engine.Engine.NewProcess()); + } + + private const string ClassModule = + "Перем Сумма;\n" + + "\n" + + "Процедура Посчитать(Повторов) Экспорт\n" + + "\tДля Н = 1 По Повторов Цикл\n" + + "\t\tСумма = Сумма + 1;\n" + + "\tКонецЦикла;\n" + + "КонецПроцедуры\n" + + "\n" + + "Сумма = 0;\n"; + + private const string MainModule = + "Перем Старт;\n" + + "\n" + + "Процедура Работа(НомерЗадания) Экспорт\n" + + "\tПока Не Старт Цикл\n" + + "\tКонецЦикла;\n" + + "\tДля Н = 0 По %КЛАССОВ% - 1 Цикл\n" + + "\t\tОбъект = Новый(\"Класс\" + Н);\n" + + "\t\tОбъект.Посчитать(%ПОВТОРОВ%);\n" + + "\t\tЗагрузитьСценарийИзСтроки(\"Перем Х; Х = \" + Формат(НомерЗадания * 100 + Н, \"ЧГ=\") + \";\");\n" + + "\tКонецЦикла;\n" + + "КонецПроцедуры\n" + + "\n" + + "Для Н = 0 По %КЛАССОВ% - 1 Цикл\n" + + "\tПодключитьСценарий(ОбъединитьПути(\"%КАТАЛОГ%\", \"Класс\" + Н + \".os\"), \"Класс\" + Н);\n" + + "КонецЦикла;\n" + + "\n" + + "Старт = Ложь;\n" + + "Задания = Новый Массив;\n" + + "Для Н = 1 По %ЗАДАНИЙ% Цикл\n" + + "\tПараметры = Новый Массив;\n" + + "\tПараметры.Добавить(Н);\n" + + "\tЗадания.Добавить(ФоновыеЗадания.Выполнить(ЭтотОбъект, \"Работа\", Параметры));\n" + + "КонецЦикла;\n" + + "Старт = Истина;\n" + + "ФоновыеЗадания.ОжидатьВсе(Задания);\n" + + "\n" + + "Ошибки = \"\";\n" + + "Для Каждого Задание Из Задания Цикл\n" + + "\tЕсли Задание.ИнформацияОбОшибке <> Неопределено Тогда\n" + + "\t\tОшибки = Ошибки + Задание.ИнформацияОбОшибке.ПодробноеОписаниеОшибки() + Символы.ПС;\n" + + "\tКонецЕсли;\n" + + "КонецЦикла;\n" + + "Если Ошибки <> \"\" Тогда\n" + + "\tВызватьИсключение Ошибки;\n" + + "КонецЕсли;\n"; + + private static void RunInParallel(int threadsCount, Action action) + { + var exceptions = new ConcurrentQueue(); + using var barrier = new Barrier(threadsCount); + var threads = Enumerable.Range(0, threadsCount).Select(_ => new Thread(() => + { + try + { + barrier.SignalAndWait(); + action(); + } + catch (Exception e) + { + exceptions.Enqueue(e); + } + })).ToArray(); + + foreach (var thread in threads) + { + thread.Start(); + } + foreach (var thread in threads) + { + thread.Join(); + } + + exceptions.Should().BeEmpty(); + } + } +}