c# notes - multithreading

parallel

Both asynchrony and parallelism are means of concurrency. Concurrency means that the execution of multiple tasks has overlapping time,
that is, when one task is not fully executed, another task is executed.

Asynchrony means that a task can be paused to do other things. In a purely waiting task, this spare time can be used to do other things.
Otherwise, only the responsiveness of the program can be provided, such as suspending and canceling operations that must be responded immediately.

Parallelism is the simultaneous execution of multiple logical processors of the CPU, which can really improve the efficiency of program execution.
Multithreading is a means of achieving parallelism. In multiple execution units in a program process,
different threads can share the data under this process. Of course, opening several programs directly is also a parallel method.

There are two prerequisites for using multithreading to optimize program execution efficiency.

  1. Must be CPU intensive. Parallelism can only squeeze the performance of the CPU, so it must be the work of the CPU to optimize.
    Under normal circumstances, the tasks are not pure CPU operations, so multi-threading still needs to be used with asynchrony.
  2. Must be able to be divided into multiple tasks. Operations such as compression and rendering can be divided into multiple blocks to allocate tasks.
    Regardless of the execution result of a task, it does not affect other tasks, and they are independent.
    But what will happen in the next second of the game is based on the operation of this second.
    If the result of moving forward is calculated in advance, but the player presses jump, then this calculation is useless.

Thread Pool

Each thread has a thread stack that controls the execution of the program.
The thread stack is mainly used to store execution information such as local variables, method calls, and where to run.
Each thread stack operates independently and does not interact with other threads. But they can share access to data on the managed heap.
In VS2022, by default, a thread stack is 4M in size. The allocation and recovery of thread stacks consume a lot of resources.

Therefore, in most cases, the thread pool will be used to obtain threads and assign tasks.
The thread pool will give you the spare thread stored, and it will sleep but will not be destroyed after you finish using it.
In this way, it will be given to you directly when you use it next time, without creating or destroying threads.

background thread

In c# we can directly use Task.Runto assign thread tasks. Because of the task scheduler and task pool, the thread pool is also used.

Task.Run(() =>
{
    
    
	for (int i = 0; i < 200; i++)
	{
    
    
		Console.Write("x");
	}
}); 
for (int i = 0; i < 200; i++)
{
    
    
	Console.Write("y");
}

But the thread task created by this method defaults to a background thread.
A program terminates when all foreground threads stop, regardless of background threads.
If the configuration is passed in during creation Task, it can be changed to a foreground thread.

// 使用TaskCreationOptions.LongRunning选项来创建前台线程
var task = Task.Factory.StartNew(() =>
{
    
    
	for (int i = 0; i < 200; i++)
	{
    
    
		Console.Write("x");
	}
}, TaskCreationOptions.LongRunning);

thread synchronization

Tasks contain ordinary c# statements, so asynchronous concurrency will at least run a statement completely before switching.
But multi-threading does not. The smallest unit is a CPU operation, which i++is divided into three steps: value acquisition, operation, and assignment.
In a multi-threaded environment, it is possible that before one thread completes the assignment, other threads will perform the value operation.
The results of these two threads are only equivalent to executing once i++.

int num = 0;
for (int i = 0; i < 1000; i++)
{
    
    
	_=Task.Run(() =>
	{
    
    
		for (int i = 0; i < 1000; i++)
		{
    
    
			num++;
		}
	});
}
await Task.Delay(100);
Console.WriteLine(num);

Lock

One of the simplest methods is to use locks, which allow a resource to be accessed only by one thread at a time.
Simple and effective, but using bad locks will make the performance as single-threaded.

The lock usage lockstatement, just usinglike the statement, is expanded into a try-finallyblock,
locked at the beginning, and finallyunlocked in the middle.

Only reference types have object headers and synchronization blocks, so unboxed value types cannot be locked.

int[] arr = {
    
     0 };
for (int i = 0; i < 1000; i++)
{
    
    
	_ = Task.Run(() =>
	{
    
    
		for (int i = 0; i < 1000; i++)
		{
    
    
			lock (arr)
			{
    
    
				arr[0]++;
			}
		}
	});
}
await Task.Delay(100);
Console.WriteLine(arr[0]);

asynchronous lock

lockCannot exist inside a block await.
If you need to use asynchronous locks, you cannot use this syntax, and you need to use a complete class instance to lock.

int num2 = 0;
SemaphoreSlim slim = new SemaphoreSlim(1);
for (int i = 0; i < 1000; i++)
{
    
    
	_ = Task.Run(async () =>
	{
    
    
		for (int i = 0; i < 1000; i++)
		{
    
    
			await slim.WaitAsync();
			try
			{
    
    
				await Task.Yield();
				num2++;
			}
			finally
			{
    
    
				slim.Release();
			}
		}
	});
}
await Task.Delay(100);
Console.WriteLine(num2);

SemaphoreSlimClasses can also achieve the effect of locking. The 1 in the constructor means that the maximum running count is 1.
This class can also simply implement functions such as the maximum number of simultaneous download tasks and the maximum number of simultaneous running tasks.

His counters are changed manually and he doesn't know if you actually use them slim.WaitAsync(). At the time, if the count is not less than the allowed amount, the async will not complete. If it is completed, it will safely let a thread enter at random, and will not enter multiple threads at the same time. In order to ensure that the count will be released, its use should also be placed in the block. When preventing an abnormal interrupt, the count will not be released.slim.Release()
await slim.WaitAsync();

try-finally

deadlock

The lock is really easy to use. But something that is too simple has a fatal problem, deadlock.
For example, thread A locks resource a1, and thread B locks resource b1.
After the lock is completed, thread A wants to access b1, and thread B wants to access a1.
Because b1 is locked, thread A cannot access it, and thread A cannot execute it, so resource a1 will not be released.
Because a1 will not be released, and thread B cannot access a1, so b1 will not be released either.

Avoiding deadlocks while ensuring maximum efficiency can only be written by people who are proficient in multithreading.
For novices, the simple way to avoid deadlock is to start with destroying his conditions.
Although efficiency will be lost, deadlock can be avoided to a large extent.

mutually exclusive

Mutual exclusion is the use of locking, and another thread cannot access it when one thread accesses it.
Of course, no lock will not cause deadlock.

Some read-only data, and creating a copy every time it is accessed, can be accessed without locks.
But doing so either fails to manipulate the data, or still fails to synchronize the data.

possession

When a thread cannot access other resources, it just waits until it can be used.
The solution to this condition is a timeout. If after a period of time, take the initiative to let go.

Asynchrony supports timeout operations, and SemaphoreSlimalso integrates the method of timeout locks.

await slim.WaitAsync(10000);//10秒钟超时

After letting go, you can retry after a certain time interval, but you need to think about the appropriate interval time and number of retries.

greedy

Lock things should be things that you can predict, not shared, don't lock thisthings that others can access. After privatizing the locked things, what will be locked should be controllable. The best way is to create static in the class or use the class to lock.stringType

objectSemaphoreSlim

Then, if after you lock one thing, you find that you want to use another thing that may be occupied,
you should release the thing you own before locking the new thing. Such a piece of code will not access two locked things at the same time.

out of order

In the example, thread A locks a1 and thread B locks b1, which is counter-intuitive in itself.
Because multiple threads should perform the same operation, the things and order they lock should be the same.
This happens because they use a flow control statement.
So one way to solve the deadlock is not to add locks in the flow control statement.

atomic operation

The word atom originates from the ancient Greek word for " indivisible ".
An atomic operation refers to an operation that is either completely executed or not executed at all, and will not be subdivided and only executed partly.

In C#, there is a System.Threading.Interlockedclass that provides some atomic operation methods, for example:

  • Interlocked.Increment: Atomically increments an integer value and returns the new value.
  • Interlocked.Decrement: Atomically decrements an integer value and returns the new value.
  • Interlocked.Exchange: Atomically sets the value of a variable to another value and returns the old value.
  • Interlocked.CompareExchange: Atomically compares the values ​​of two variables, and if equal, sets the value of the first variable to the other and returns the old value.

Atomic operations are usually implemented through CPU instructions or memory barriers,
so intoperations of these basic types are not directly made into atomic operations.

The parameters of these methods are all reference variables. Operations directly on memory are guaranteed to complete.
When using the atomic operation method, you need to pay attention to the following points:

  • Atomic operations can only guarantee the atomicity of a single operation. If you need to calculate expressions or deal with complex types, then you still need to use locks or other synchronization mechanisms.
  • Atomic operations can only guarantee visibility and order, but not consistency. That is, atomic operations guarantee that modifications to a variable by one thread are visible to other threads and will not be reordered. However, if multiple threads modify the same variable at the same time, the final result may not be what you expected.

Sequentiality means that operations that do not depend on data will be considered by the compiler to be irrelevant to who executes first and who executes later. For example:

int a = 1;
int b = 2;
int c = a + b;

cThe assignment of and has a dependency on aand b, but the order of declaration and assignment of aand bdoes not matter. The compiler may adjust the order depending on the CPU.

Visibility means that some data may be placed in the CPU cache. Updating the content with atomic operations invalidates those caches and
they have to fetch the latest data from memory. So the possibility of data inconsistency can be reduced.

Parallel Linq

The method used for Linq sequence AsParallel()can be switched to parallel Linq sequence.
Parallel Linq sequences partition data to distribute it into multiple tasks for parallel execution.
But Parallel Linq has two disadvantages:

  • Parallel Linq cannot guarantee order, and order-related methods are almost the same as random. Preserving the order costs a lot of performance.
    • AsOrderedMethods can preserve order.
    • AsUnorderedThe method representation no longer needs to maintain order.
    • AsSequentialThe method will then turn into a normal Linq sequence.
  • The partition itself needs to consume resources. For extremely small operations or sequences, partitioning may take more time than processing.

Guess you like

Origin blog.csdn.net/zms9110750/article/details/130767909