public void ClosureMethod_Registry_GC_Multithreaded([Values] bool gcAtIntervals) { var threadCount = 10; var delegatePerThreadCount = 10; var delegatePerThreadGCInterval = 5; MethodClosureExtensionsFixture.Do(fixture => { fixture.AssertClosureRegistryCountAfterFullGCFinalization(0, "after initial GC"); var method = typeof(MethodClosureExtensionsTestsFancy).GetMethod(nameof(MethodClosureExtensionsTestsFancy.FancyStaticNonVoidMethod)); var partialAppliedMethod = method.PartialApply("hello", "world", new TestStruct(10), new TestStruct(15), 20, 25, new TestClass(30), new TestClass(35)); var threads = new Thread[threadCount]; var partialAppliedDelegatePerThread = new MethodClosureExtensionsTestsFancy.FancyStaticNonVoidMethod_PartialApply_Delegate[threadCount]; var unexpectedExceptionPerThread = new Exception[threadCount]; var threadStartResetEvent = new ManualResetEvent(false); for (var j = 0; j < threadCount; j++) { // Need a copy of the iteration variable for usage within the thread, since direct access to it is effectively by-ref, // such that within the threads, it would only see it as its final value (threadCount). var threadIndex = j; var thread = new Thread(() => { // Wait until all threads are started (threadStartResetEvent.Set() is called in the main thread once all threads are started). //Logging.Log($"DEBUG thread {threadIndex} init"); threadStartResetEvent.WaitOne(); Logging.Log($"DEBUG thread {threadIndex} started"); try { for (var i = 0; i < delegatePerThreadCount; i++) { // This local function ensures that any (implicit) local variables within its body are finalizable afterwards. // This is a workaround for the issue where local variables in a method are not finalizable after last usage in DEBUG builds. void CreateTestDelegate() { // Note: CreateDelegate has to go within the lock (if locking is needed), since it modifies the closure registry. partialAppliedDelegatePerThread[threadIndex] = partialAppliedMethod.CreateDelegate <MethodClosureExtensionsTestsFancy.FancyStaticNonVoidMethod_PartialApply_Delegate>(); if (i % delegatePerThreadGCInterval == 0) { // Test that the partially applied method delegate still works in this thread. //Logging.Log($"DEBUG before delegate call {i / delegatePerThreadGCInterval} in thread {threadIndex}"); var x = threadIndex * i; partialAppliedDelegatePerThread[threadIndex](null, new List <string>() { "qwerty" }, x, ref x); Assert.AreEqual(threadIndex * i * threadIndex * i, x); } } if (gcAtIntervals) { lock (fixture.GCSync) CreateTestDelegate(); } else { CreateTestDelegate(); } if (i % delegatePerThreadGCInterval == 0 && gcAtIntervals) { lock (fixture.GCSync) { Logging.Log($"DEBUG before GC {i / delegatePerThreadGCInterval} in thread {threadIndex}:\n{ClosureMethod.Registry}"); var delegateCount = partialAppliedDelegatePerThread.Where(@delegate => !(@delegate is null)).Count(); var logLabel = $"after GC {i / delegatePerThreadGCInterval} in thread {threadIndex}"; Assert.Greater(delegateCount, 0, logLabel); fixture.AssertClosureRegistryCountAfterFullGCFinalization(delegateCount, logLabel); } } } } catch (Exception exception) { unexpectedExceptionPerThread[threadIndex] = exception; Logging.Log($"DEBUG exception in thread {threadIndex}:\n{exception}"); throw; } finally { Logging.Log($"DEBUG thread {threadIndex} finished"); } }); threads[threadIndex] = thread; thread.Start(); } var timeoutThread = new Thread(() => { var timeout = TimeSpan.FromSeconds(20); Thread.Sleep(timeout); Logging.Log($"DEBUG timeout after {timeout.Seconds} seconds - aborting all test threads"); foreach (var thread in threads) { if (thread.IsAlive) { thread.Abort(); } } }); timeoutThread.Start(); threadStartResetEvent.Set(); foreach (var thread in threads) { thread.Join(); } for (var threadIndex = 0; threadIndex < threadCount; threadIndex++) { var exception = unexpectedExceptionPerThread[threadIndex]; if (!(exception is null)) { // Wrapping in a new exception to preserve the stack trace (since throw sets thrown exception's stack trace). throw new Exception("Unexpected exception", exception); } } lock (fixture.GCSync) { //Logging.Log($"DEBUG before GC after all threads joined:\n{ClosureMethod.Registry}"); fixture.AssertClosureRegistryCountAfterFullGCFinalization(threadCount, "after GC after all threads joined"); } for (var threadIndex = 0; threadIndex < threadCount; threadIndex++) { // Test that the latest partially applied method delegate still works. //Logging.Log($"DEBUG before final delegate call created in thread {threadIndex} and called in main thread"); var x = threadIndex; partialAppliedDelegatePerThread[threadIndex](null, new List <string>() { "qwerty" }, 40L, ref x); Assert.AreEqual(threadIndex * threadIndex, x); // TODO: Why is this now necessary? Shouldn't partialAppliedDelegatePerThread going out of scope be sufficient? partialAppliedDelegatePerThread[threadIndex] = null; } //lock (fixture.GCSync) //{ // Logging.Log($"DEBUG before final GC:\n{ClosureMethod.Registry}"); // // MethodClosureExtensionsFixture will call AssertEmptyClosureRegistryAfterTryFullGCFinalization a final time, // // so don't need to call it here. //} }); }
public void ClosureMethod_Registry_GC([Values] bool gcAtIntervals) { int delegateCount = 20; int gcInterval = 5; MethodClosureExtensionsFixture.Do(fixture => { fixture.AssertClosureRegistryCountAfterFullGCFinalization(0, "after initial GC"); var method = typeof(MethodClosureExtensionsTestsFancy).GetMethod(nameof(MethodClosureExtensionsTestsFancy.FancyStaticNonVoidMethod)); MethodClosureExtensionsTestsFancy.FancyStaticNonVoidMethod_PartialApply_Delegate partialAppliedDelegate1 = null, partialAppliedDelegate2 = null; for (var i = 1; i <= delegateCount; i++) { var partialAppliedMethod = method.PartialApply("hello", "world", new TestStruct(10), new TestStruct(15), 20, 25, new TestClass(30), new TestClass(35)); partialAppliedDelegate1 = partialAppliedMethod.CreateDelegate <MethodClosureExtensionsTestsFancy.FancyStaticNonVoidMethod_PartialApply_Delegate>(); partialAppliedDelegate2 = partialAppliedMethod.CreateDelegate <MethodClosureExtensionsTestsFancy.FancyStaticNonVoidMethod_PartialApply_Delegate>(); if (i % 5 == 0 && gcAtIntervals) { //Logging.Log($"DEBUG before GC {i / gcInterval}:\n{ClosureMethod.Registry}"); // Note: Since GCs can be triggered at any moment before this point, we can't deterministically determine # active closures are in the registry. // We can only deterministically determine the # active closures after a "full" GC and before any further ClosureMethod.CreateDelegate calls. fixture.AssertClosureRegistryCountAfterFullGCFinalization(2, $"after GC {i / gcInterval}"); } } if (!gcAtIntervals) { fixture.AssertClosureRegistryCountAfterFullGCFinalization(2, $"after GC {delegateCount / gcInterval}"); } // Test that the latest partially applied method delegate still works. var x = 20; partialAppliedDelegate1(null, new List <string>() { "qwerty" }, 40L, ref x); Assert.AreEqual(20 * 20, x); partialAppliedDelegate2(null, new List <string>() { "qwerty" }, 40L, ref x); Assert.AreEqual(20 * 20 * 20 * 20, x); //Logging.Log($"DEBUG before final GC:\n{ClosureMethod.Registry}"); // MethodClosureExtensionsFixture will call AssertEmptyClosureRegistryAfterTryFullGCFinalization a final time, so don't need to call it here. }); }