65  
guide
Поиск  
Always will be ready notify the world about expectations as easy as possible: job change page
May 13, 2023

Mastering Async and Await in C#: In-Depth Guide

Автор:
Juan Alberto España Garcia
Источник:
Просмотров:
4808

Async and Await in C#

Introduction to Async and Await in C#

Asynchronous programming has come a long way in C#. Prior to the introduction of async and await, developers had to rely on callbacks, events and other techniques like the BeginXXX/EndXXX pattern or BackgroundWorker.

These methods often led to complex and difficult-to-maintain code. With the release of C# 5.0, async and await keywords were introduced, simplifying asynchronous programming and making it more accessible to developers.

The Importance of Async and Await in Modern Applications

Async and await are essential for creating responsive and scalable applications. Modern applications often handle multiple tasks simultaneously, such as fetching data from the internet, processing files, or performing complex calculations.

By using async and await, developers can offload these tasks to a separate thread, allowing the main thread to remain responsive and handle user interactions.

Understanding the Async and Await Keywords

The async and await keywords are fundamental to asynchronous programming in C#. They work together to simplify the process of writing non-blocking code, making it easier to read and maintain. Let’s take a closer look at its explanation:

Async Keyword

The async keyword is used to mark a method as asynchronous. It indicates that the method can perform a non-blocking operation and return a Task or Task<TResult> object. Here are some features of the async keyword:

  • It can be applied to methods, lambda expressions and anonymous methods.
  • It cannot be used with properties or constructors.
  • An async method should contain at least one await expression.
  • An async method can have multiple await expressions, allowing for multiple non-blocking operations.
  • Async methods can be chained together, allowing for complex asynchronous workflows.

Await Keyword

The await keyword is used within an async method to temporarily suspend its execution and yield control back to the calling method until the awaited task is completed.

This allows other tasks to continue executing in the meantime, ensuring that the application remains responsive. Some features of the await keyword include:

  • It can only be used within an async method.
  • It can be applied to any expression that returns a Task or Task<TResult> object.
  • It unwraps the result of the Task<TResult> object, allowing you to work with the result directly.
  • It automatically handles exceptions thrown by the awaited task, allowing you to catch and handle them in the calling async method.
  • It can be used with using, foreach and lock statements in C# 8.0 and later.

Pros and Cons of Async and Await

Using async and await in C# offers several benefits, including:

  • Simplified asynchronous code: Async and await make writing asynchronous code much simpler and more readable, resembling synchronous code while still providing the benefits of asynchronous execution.
  • Improved application responsiveness: By offloading time-consuming tasks to separate threads, async and await can help make your application more responsive and user-friendly.
  • Efficient resource utilization: Asynchronous programming allows your application to make better use of system resources, such as CPU, memory and I/O.
  • Easier exception handling: The await keyword automatically handles exceptions thrown by awaited tasks, simplifying exception handling in asynchronous code.

However, there are also some potential drawbacks to consider:

  • Overhead: Using async and await can introduce a small performance overhead compared to synchronous code, as it involves creating and managing tasks. However, this overhead is usually negligible compared to the benefits of improved responsiveness and resource utilization.
  • Potential for deadlocks: Incorrect use of async and await can lead to deadlocks, especially when mixing synchronous and asynchronous code. It is essential to follow best practices and avoid common pitfalls to prevent deadlocks.
  • Learning curve: Asynchronous programming can be challenging to learn and understand, especially for developers who are new to the concept. It requires a solid understanding of tasks, threading and other related concepts.

Key Differences Between Async and Await

  • The async keyword is used to mark a method as asynchronous, while the await keyword is used to temporarily suspend the execution of an async method and yield control back to the calling method until the awaited task is completed.
  • The async keyword is applied to methods, lambda expressions and anonymous methods, whereas the await keyword is used within an async method and can be applied to any expression returning a Task or Task<TResult> object.
  • The async keyword indicates that a method can perform non-blocking operations, whereas the await keyword enables other tasks to continue executing while the async method’s execution is temporarily suspended.

Getting Started with Async and Await in C#

Writing Your First Async Method in C#

To create an async method, you need to declare the method with the async keyword and return a Task or Task<TResult> object. Here’s a simple example of an async method:

// Declare an async method that returns a Task<string>
public async Task<string> FetchDataAsync()
{
    // Create a new instance of HttpClient
    using (var httpClient = new HttpClient())
    {
        // Use the await keyword to perform a non-blocking GET request
        string result = await httpClient.GetStringAsync("https://example.com/data");

        // Return the result when the request is completed
        return result;
    }
}

Incorporating Await in Async Methods

The await keyword is used to temporarily suspend the execution of an async method and yield control back to the calling method until the awaited task is completed. In the example below, the await keyword is used with the GetStringAsync method, which returns a Task<string> object.

The execution of the FetchDataAsync method is temporarily suspended until the GetStringAsync method is completed, allowing other tasks to continue executing in the meantime.

To demonstrate how to incorporate await in async methods, let’s extend the FetchDataAsync example by adding a continuation:

public async Task<string> FetchAndProcessDataAsync()
{
    // Call the FetchDataAsync method and await its completion
    string rawData = await FetchDataAsync();

    // Process the raw data (e.g., parsing JSON, XML, or other transformations)
    string processedData = ProcessData(rawData);
    // Return the processed data
    return processedData;
}

private string ProcessData(string rawData)
{
    // Implement your data processing logic here
    return rawData.ToUpper();
}

Exploring Real-World Async and Await C# Examples

Async and await can be used in various real-world scenarios, such as fetching data from an API, reading or writing files, or performing CPU-bound operations. Here are some examples to demonstrate their flexibility and usefulness:

Reading a file asynchronously

public async Task<string> ReadFileAsync(string filePath)
{
    // Create a new instance of StreamReader with the specified file path
    using (var reader = new StreamReader(filePath))
    {
        // Use the await keyword to read the file content asynchronously
        string content = await reader.ReadToEndAsync();

        // Return the content of the file when the read operation is completed
        return content;
    }
}

Making multiple API calls concurrently

public async Task<IEnumerable<string>> FetchMultipleDataAsync(IEnumerable<string> urls)
{
    // Create a new instance of HttpClient
    using (var httpClient = new HttpClient())
    {
        // Initiate multiple GetStringAsync tasks concurrently
        var tasks = urls.Select(url => httpClient.GetStringAsync(url));

        // Await the completion of all tasks using Task.WhenAll
        string[] results = await Task.WhenAll(tasks);
        // Return the fetched data as an IEnumerable<string>
        return results;
    }
}

Performing a CPU-bound operation asynchronously

public async Task<int> CalculateResultAsync(int input)
{
    // Offload the CPU-bound operation to a separate thread using Task.Run
    int result = await Task.Run(() => PerformComplexCalculation(input));

    // Return the result when the calculation is completed
    return result;
}

private int PerformComplexCalculation(int input)
{
    // Implement your complex calculation logic here
    return input * 2;
}

These examples showcase how async and await can be used in different real-world situations, improving application responsiveness and efficiently utilizing system resources. Remember to follow best practices and handle exceptions properly to ensure robust and maintainable asynchronous code.

Advanced Concepts in Async and Await C#

The Task Class and Its Role in Asynchronous Programming

The Task class represents an asynchronous operation that can be awaited using the await keyword. It provides several methods to create and manipulate tasks, such as Task.Run, Task.FromResult and Task.WhenAll. The Task<TResult> class, a subclass of Task, represents an asynchronous operation that returns a value of type TResult.

Task Continuations

Task continuations allow you to specify additional work to be executed when a task has completed. You can use the ContinueWith method to attach a continuation to a task. This can be useful for chaining multiple asynchronous operations or handling the result of an asynchronous operation in a specific way:

public async Task<string> FetchDataAndSaveToFileAsync(string url, string filePath)
{
    var dataTask = FetchDataAsync(url);

    // Attach a continuation to save the fetched data to a file
    var saveDataTask = dataTask.ContinueWith(async t =>
    {
        var data = t.Result;
        using (var writer = new StreamWriter(filePath))
        {
            await writer.WriteAsync(data);
        }
    });
    // Await the completion of the saveDataTask
    await saveDataTask;
}

Keep in mind that the ContinueWith method doesn’t automatically unwrap the result of the antecedent task. Therefore, you must access the Result property of the antecedent task to retrieve its result.

Task Combinators

Tasks can be combined using static methods provided by the Task class, such as Task.WhenAll and Task.WhenAny. These methods allow you to perform parallel operations, wait for multiple tasks to complete, or continue execution when the first task is completed:

  • Task.WhenAll: Awaits the completion of all tasks in a given collection and returns a single task containing the results of each completed task. This is useful for performing multiple asynchronous operations concurrently and processing their results when all are completed.
  • Task.WhenAny: Awaits the completion of any task in a given collection and returns the first completed task. This is useful when you have multiple tasks that perform similar operations and you only need the result of the first one to complete.

Understanding ConfigureAwait and Its Usage in C#

ConfigureAwait is a method provided by the Task and Task<TResult> classes. It allows developers to configure how the context is captured and restored when the awaited task is completed. By default, the await keyword captures the current synchronization context and resumes the execution on the same context.

However, in certain scenarios, such as in libraries or when optimizing performance, it is beneficial to avoid capturing the context. This can be achieved by using ConfigureAwait(false):

string result = await httpClient.GetStringAsync("https://example.com/data").ConfigureAwait(false);

Tips for Using ConfigureAwait

  • In general, use ConfigureAwait(false) in library code to avoid potential deadlocks and improve performance.
  • In UI applications, avoid using ConfigureAwait(false) when you need to update UI elements after an awaited operation, as this requires the original UI context to be captured.
  • Be aware of the potential for deadlocks when mixing synchronous and asynchronous code. Using ConfigureAwait(false) can help prevent deadlocks in some scenarios, but it’s not a universal solution.

The Difference Between Synchronous and Asynchronous Programming in C#

Synchronous programming executes tasks sequentially, blocking the execution of the calling thread until the current task is completed. In contrast, asynchronous programming allows tasks to be executed concurrently without blocking the calling thread.

Async and await enable developers to write asynchronous code that is easier to read and maintain, resembling synchronous code while still providing the benefits of asynchronous execution.

Curious Features of Async and Await in C#

As a C# developer, you might be interested in exploring lesser-known features and nuances of async and await:

  • Custom awaiters: It’s possible to create custom awaiters by implementing the INotifyCompletion or ICriticalNotifyCompletion interfaces and providing a GetAwaiter method for a specific type. This allows you to use the await keyword with custom types, enabling advanced scenarios and optimizations. 
  • Async streams: In C# 8.0, you can use the IAsyncEnumerable<T> interface and await foreach to work with asynchronous streams. This allows you to asynchronously enumerate and process collections of items that are produced or fetched asynchronously. 
  • Asynchronous disposal: C# 8.0 also introduced the IAsyncDisposable interface, which provides an async version of the Dispose method called DisposeAsync. This enables you to perform asynchronous cleanup operations when disposing of resources.

Async and Await Best Practices

  • Use the async keyword on methods that contain at least one await expression, ensuring that the method signature clearly indicates its asynchronous nature.
  • Return Task or Task<TResult> instead of void in async methods, as this enables better error handling and allows the caller to await the result.
  • Avoid using async void methods, as they can’t be awaited and can lead to unhandled exceptions. Instead, use async Task methods for event handlers, which provide proper exception handling.
  • Use ConfigureAwait(false) when possible to avoid capturing the context, especially in library code or when optimizing performance. This reduces the risk of deadlocks and improves efficiency.
  • Use Task.Run for CPU-bound operations that can benefit from parallelism, effectively offloading the work to a separate thread and preventing the main thread from being blocked.
  • Limit the number of concurrent tasks when using async methods in loops, using techniques such as SemaphoreSlim or Task.WhenAll with a limited number of tasks to avoid excessive resource usage.

Error Handling and Exception Handling in Async Methods

When using async and await, it is essential to handle exceptions correctly. Exceptions in async methods can be caught using a try-catch block, similar to synchronous code:

public async Task<string> FetchDataAsync()
{
    try
    {
        using (var httpClient = new HttpClient())
        {
            string result = await httpClient.GetStringAsync("https://example.com/data");
            return result;
        }
    }
    catch (HttpRequestException ex)
    {
        // Handle the exception
        return null;
    }
}

It’s important to note that when an exception is thrown in an awaited task, the exception is propagated to the calling async method, allowing you to catch and handle it. Moreover, when using Task.WhenAll, you can catch multiple exceptions by accessing the Task.Exception property:

public async Task ExecuteMultipleTasksAsync()
{
    var tasks = new List<Task>
    {
        FetchDataAsync("https://example.com/data1"),
        FetchDataAsync("https://example.com/data2"),
        FetchDataAsync("https://example.com/data3")
    };

    try
    {
        await Task.WhenAll(tasks);
    }
    catch (AggregateException ex)
    {
        foreach (var innerEx in ex.InnerExceptions)
        {
            // Handle each inner exception
        }
    }
}

C# Async Programming: Tips and Tricks

  • Use the ValueTask<TResult> struct for high-performance scenarios where the result is often available synchronously. This can help reduce memory allocations and improve performance:

    public async ValueTask<int> CalculateResultAsync(int input)
    {
        if (input < 0)
        {
            return -1;
        }

        int result = await Task.Run(() => PerformComplexCalculation(input));
        
        return result;
    }
     
  • Combine multiple tasks using Task.WhenAll or Task.WhenAny to perform parallel operations, improving the overall efficiency of your application:

    public async Task ProcessDataAsync()
    {
        Task<string> fetchDataTask = FetchDataAsync();
        Task<string> readDataTask = ReadDataAsync();

        await Task.WhenAll(fetchDataTask, readDataTask);
        string fetchedData = fetchDataTask.Result;
        string readData = readDataTask.Result;
        // Process the data
    }
     
  • Use CancellationToken to cancel long-running tasks gracefully, enabling better resource management and preventing unnecessary work:

    public async Task<string> FetchDataAsync(CancellationToken cancellationToken)
    {
        using (var httpClient = new HttpClient())
        {
            cancellationToken.ThrowIfCancellationRequested();

            string result = await httpClient.GetStringAsync("https://example.com/data").ConfigureAwait(false);
            return result;
        }
    }

    public async Task ProcessDataAsync()
    {
        var cancellationTokenSource = new CancellationTokenSource();
        try
        {
            string data = await FetchDataAsync(cancellationTokenSource.Token);
            // Process the data
        }
        catch (OperationCanceledException)
        {
            // Handle the cancellation
        }
        cancellationTokenSource.Dispose();
    }
     
  • When dealing with loops and async methods, limit the number of concurrent tasks to avoid excessive resource usage. This can be achieved using techniques such as SemaphoreSlim:

    public async Task ProcessMultipleFilesAsync(IEnumerable<string> filePaths)
    {
        var semaphore = new SemaphoreSlim(4); // Limit to 4 concurrent tasks

        var tasks = filePaths.Select(async filePath =>
        {
            await semaphore.WaitAsync();
            try
            {
                await ProcessFileAsync(filePath);
            }
            finally
            {
                semaphore.Release();
            }
        });
        await Task.WhenAll(tasks);
    }

Implementing Async and Await in Real-World Scenarios

Async and Await in Web Applications

Async and await can significantly improve the responsiveness and scalability of web applications by allowing the server to handle more incoming requests concurrently. Using async and await in web applications typically involves the following scenarios:

  • Fetching data from a database: When querying a database, leverage async and await to avoid blocking the main thread. Most database libraries, such as Entity Framework Core, support async methods for querying and saving data:

    public async Task<List<Customer>> GetCustomersAsync()
    {
        using (var context = new MyDbContext())
        {
            return await context.Customers.ToListAsync();
        }
    }
     
  • Calling external APIs: Use async and await when making HTTP requests to external APIs to keep the application responsive during network latency:

    public async Task<HttpResponseMessage> GetWeatherDataAsync(string city)
    {
        using (var httpClient = new HttpClient())
        {
            var response = await httpClient.GetAsync($"https://api.example.com/weather/{city}");
            return response;
        }
    }
     
  • Uploading and processing files: When handling file uploads, use async and await to read and process the uploaded files without blocking the main thread:

    public async Task<string> SaveUploadedFileAsync(IFormFile file)
    {
        var targetPath = Path.Combine("uploads", file.FileName);
        using (var fileStream = new FileStream(targetPath, FileMode.Create))
        {
            await file.CopyToAsync(fileStream);
        }
        return targetPath;
    }

Asynchronous File I/O Operations in C#

File I/O operations, such as reading or writing files, can benefit from async and await, as they often involve waiting for the file system or network resources. The System.IO namespace provides several asynchronous methods for file operations:

  • Reading a file asynchronously: Use StreamReader.ReadToEndAsync to read a file without blocking the main thread:

    public async Task<string> ReadFileAsync(string filePath)
    {
        using (var reader = new StreamReader(filePath))
        {
            string content = await reader.ReadToEndAsync();
            return content;
        }
    }
     
  • Writing to a file asynchronously: Use StreamWriter.WriteAsync or FileStream.WriteAsync to write data to a file without blocking the main thread:

    public async Task WriteToFileAsync(string filePath, string content)
    {
        using (var writer = new StreamWriter(filePath))
        {
            await writer.WriteAsync(content);
        }
    }
     
  • Asynchronous file copy: Use Stream.CopyToAsync to copy the content of one stream to another asynchronously, which can be useful when working with files or network streams:

    public async Task CopyFileAsync(string sourcePath, string destinationPath)
    {
        using (var sourceStream = File.OpenRead(sourcePath))
        using (var destinationStream = File.Create(destinationPath))
        {
            await sourceStream.CopyToAsync(destinationStream);
        }
    }

Implementing Async and Await in APIs and Microservices

APIs and microservices can greatly benefit from async and await, as they often involve calling other services, handling multiple requests concurrently, or performing time-consuming operations. Here are some scenarios where async and await can be helpful in APIs and microservices:

  • Asynchronous API endpoints: Use async and await in your API controller methods to keep your API responsive and handle more incoming requests concurrently:

    [HttpGet("{id}")]
    public async Task<ActionResult<Product>> GetProductAsync(int id)
    {
        var product = await _productService.GetProductByIdAsync(id);
        if (product == null)
        {
            return NotFound();
        }

        return product;
    }
     
  • Calling other services or APIs: When your microservices communicate with other services or APIs, use async and await to perform these calls concurrently and improve performance:

    public async Task<User> GetUserAsync(int userId)
    {
        var userTask = _userRepository.GetUserByIdAsync(userId);
        var userOrdersTask = _orderRepository.GetOrdersByUserIdAsync(userId);

        await Task.WhenAll(userTask, userOrdersTask);
        var user = userTask.Result;
        user.Orders = userOrdersTask.Result;
        return user;
    }
     
  • Concurrent processing of messages: When processing messages from a queue or event stream, use async and await to process multiple messages concurrently, improving throughput:

    public async Task ProcessMessagesAsync(IEnumerable<Message> messages)
    {
        var processingTasks = messages.Select(message => ProcessMessageAsync(message));
        await Task.WhenAll(processingTasks);
    }

Performance Optimization with Async and Await in C#

Async and await can help improve the performance of your applications by efficiently utilizing system resources and reducing the number of blocked threads. Some techniques for optimizing performance with async and await include:

  • Using ConfigureAwait(false): To avoid capturing the context and resuming execution on the same context, use ConfigureAwait(false) when possible:

    public async Task<List<Product>> GetProductsAsync()
    {
        using (var context = new MyDbContext())
        {
            return await context.Products.ToListAsync().ConfigureAwait(false);
        }
    }
     
  • Using Task.Run for CPU-bound operations: Offload CPU-bound operations that can benefit from parallelism to a separate thread using Task.Run:

    public async Task<int> CalculateResultAsync(int input)
    {
        int result = await Task.Run(() => PerformComplexCalculation(input));
        return result;
    }
     
  • Batching and parallelism: When executing multiple independent tasks, use Task.WhenAll to run them concurrently and await their completion:

    public async Task ProcessTasksAsync(IEnumerable<Task> tasks)
    {
        await Task.WhenAll(tasks);
    }
     
  • Caching: For time-consuming operations that produce the same result when called with the same input, consider caching the result to avoid performing the operation multiple times:

    private readonly ConcurrentDictionary<int, string> _cache = new ConcurrentDictionary<int, string>();

    public async Task<string> GetCachedDataAsync(int key)
    {
        return await _cache.GetOrAddAsync(key, async k => await FetchDataAsync(k));
    }

Exploring Other Aspects of Asynchronous Programming in C#

Understanding Task.Run and Its Usage Patterns

Task.Run is a method provided by the Task class that allows you to offload a synchronous or asynchronous operation to a separate thread, returning a Task or Task<TResult> object that represents the operation.

This can be useful when performing CPU-bound operations that can benefit from parallelism or when running long-running tasks that shouldn’t block the main thread. Here are some common usage patterns of Task.Run:

  • Offloading CPU-bound operations: Use Task.Run to parallelize compute-bound operations, such as processing large datasets or performing complex calculations:

    public async Task<List<int>> ProcessDataAsync(List<int> data)
    {
        var results = await Task.Run(() => data.Select(x => PerformComplexCalculation(x)).ToList());
        return results;
    }
     
  • Running long-running tasks: When you need to run a long-running task that shouldn’t block the main thread, use Task.Run to offload it to the thread pool:

    public async Task MonitorSystemAsync(CancellationToken cancellationToken)
    {
        await Task.Run(() =>
        {
            while (!cancellationToken.IsCancellationRequested)
            {
                PerformSystemMonitoring();
                Thread.Sleep(TimeSpan.FromSeconds(30));
            }
        }, cancellationToken);
    }
     
  • Combining synchronous and asynchronous code: If you need to call a synchronous method within an async method, you can use Task.Run to offload the synchronous operation to a separate thread:

    public async Task<string> ReadFileSyncWrapperAsync(string filePath)
    {
        // Offload the synchronous ReadFileSync method to a separate thread
        string content = await Task.Run(() => ReadFileSync(filePath));
        return content;
    }

The Relationship Between Task, Async and Await in C#

The Task class represents an asynchronous operation that can be awaited using the await keyword. The async keyword is used to mark a method as asynchronous, indicating that it can perform a non-blocking operation and return a Task or Task<TResult> object.

The await keyword is then used within an async method to temporarily suspend its execution and yield control back to the calling method until the awaited task is completed.

Here’s an example illustrating the relationship between Task, async and await:

// The Task class represents the asynchronous operation
public Task<int> PerformAsyncOperation()
{
    return Task.Run(() => PerformComplexCalculation());
}

// The async keyword marks the method as asynchronous
public async Task<int> ProcessDataAsync()
{
    // The await keyword is used to temporarily suspend the execution until the awaited task is completed
    int result = await PerformAsyncOperation();
    return result * 2;
}

C# Asynchronous Programming: Advanced Topics and Resources

As you dive deeper into asynchronous programming in C#, you may come across advanced topics such as:

  • Parallel and PLINQ for data parallelism: These libraries provide support for data parallelism, allowing you to efficiently process large datasets by dividing the work across multiple cores or processors.
  • SemaphoreSlim and Mutex for synchronization and concurrency control: These synchronization primitives help you coordinate and control access to shared resources in concurrent scenarios.
  • Channel<T> and BlockingCollection<T> for producer-consumer scenarios: These collections enable you to implement producer-consumer patterns, where one or more threads produce data while other threads consume it.
  • Custom TaskScheduler and SynchronizationContext implementations: For advanced scenarios, you might need to create custom task schedulers or synchronization contexts to control how tasks are scheduled and executed.

Some additional resources for learning about these advanced topics include:

Conclusion

To resume the topic:

  • Async and await simplify asynchronous programming in C#, making it more accessible and maintainable.
  • Async and await enable developers to create responsive and scalable applications that efficiently utilize system resources.
  • Properly handling exceptions and following best practices are crucial for successful async programming.
  • Async and await can be used in various scenarios, such as web applications, file I/O operations, APIs and microservices.
  • Advanced concepts, such as ConfigureAwait, Task.Run and CancellationToken, can further enhance the performance and flexibility of async programming.
Похожее
Jun 28
Author: ByteHide
Are you preparing for an interview that will involve C# asynchronous programming? You’ve come to the right place! This comprehensive article covers a wide range of C# async-await interview questions that will test your understanding of the async-await pattern, along...
May 12, 2023
Author: Alex Maher
Language Integrated Query (LINQ) is a powerful feature in C# .NET that allows developers to query various data sources using a consistent syntax. In this article, we’ll explore some advanced LINQ techniques to help you level up your skills and...
Jan 13, 2023
Author: Jaydeep Patil
We are going to discuss the Unit of Work design pattern with the help of a generic repository and step-by-step implementation using .NET Core 6 Web API. Agenda Repository Pattern Unit of Work Step-by-step Implementation Prerequisites Visual Studio 2022 SQL...
Oct 17
Author: walter Torricos
Intro What is the Result pattern? Basically it is a great way to write error-tolerant code that can be composed. Do you feel that phrase sounds familiar? If you like F# you are right, I’ve taken it from the Results...
Написать сообщение
Тип
Почта
Имя
*Сообщение
RSS