Làm cách nào để ngăn chặn sự liên tục đồng bộ trên một Tác vụ?


82

Tôi có một số mã thư viện (mạng ổ cắm) cung cấp TaskAPI dựa trên cơ sở cho các phản hồi đang chờ xử lý cho các yêu cầu, dựa trên TaskCompletionSource<T>. Tuy nhiên, có một điều khó chịu trong TPL là dường như không thể ngăn chặn sự liên tục đồng bộ. Những gì tôi sẽ thích để có thể làm là một trong hai:

  • nói với một TaskCompletionSource<T>điều đó là không nên cho phép người gọi đính kèm TaskContinuationOptions.ExecuteSynchronously, hoặc
  • đặt kết quả ( SetResult/ TrySetResult) theo cách chỉ định TaskContinuationOptions.ExecuteSynchronouslynên bỏ qua, thay vào đó sử dụng nhóm

Cụ thể, vấn đề tôi gặp phải là dữ liệu đến đang được xử lý bởi một trình đọc chuyên dụng và nếu một người gọi có thể đính kèm với TaskContinuationOptions.ExecuteSynchronouslyhọ có thể làm ngưng trệ trình đọc (điều này ảnh hưởng nhiều hơn đến họ). Trước đây, tôi đã giải quyết vấn đề này bằng một số hackery phát hiện xem có bất kỳ sự liên tục nào hay không và nếu có thì nó sẽ đẩy việc hoàn thành lên ThreadPool, tuy nhiên điều này có tác động đáng kể nếu người gọi đã bão hòa hàng đợi công việc của họ, vì quá trình hoàn thành sẽ không được xử lý một cách hợp thời. Nếu họ đang sử dụng Task.Wait()(hoặc tương tự), về cơ bản họ sẽ tự bế tắc. Tương tự như vậy, đây là lý do tại sao người đọc sử dụng một chuỗi chuyên dụng hơn là sử dụng công nhân.

Vì thế; trước khi tôi thử và cằn nhằn nhóm TPL: tôi có thiếu một lựa chọn không?

Những điểm chính:

  • Tôi không muốn người gọi bên ngoài có thể cướp chuỗi của tôi
  • Tôi không thể sử dụng ThreadPoolnhư một triển khai, vì nó cần hoạt động khi nhóm bão hòa

Ví dụ bên dưới tạo ra sản lượng (đặt hàng có thể thay đổi tùy theo thời gian):

Continuation on: Main thread
Press [return]
Continuation on: Thread pool

Vấn đề là thực tế là một người gọi ngẫu nhiên đã quản lý để có được sự tiếp tục trên "Main thread". Trong mã thực, điều này sẽ làm gián đoạn trình đọc chính; những điều tồi tệ!

Mã:

using System;
using System.Threading;
using System.Threading.Tasks;

static class Program
{
    static void Identify()
    {
        var thread = Thread.CurrentThread;
        string name = thread.IsThreadPoolThread
            ? "Thread pool" : thread.Name;
        if (string.IsNullOrEmpty(name))
            name = "#" + thread.ManagedThreadId;
        Console.WriteLine("Continuation on: " + name);
    }
    static void Main()
    {
        Thread.CurrentThread.Name = "Main thread";
        var source = new TaskCompletionSource<int>();
        var task = source.Task;
        task.ContinueWith(delegate {
            Identify();
        });
        task.ContinueWith(delegate {
            Identify();
        }, TaskContinuationOptions.ExecuteSynchronously);
        source.TrySetResult(123);
        Console.WriteLine("Press [return]");
        Console.ReadLine();
    }
}

2
Tôi sẽ cố gắng kết TaskCompletionSourcehợp với API của riêng mình để ngăn chặn cuộc gọi trực tiếp đến ContinueWith, vì cả hai đều không TaskCompletionSourcevà cũng Taskkhông phù hợp để kế thừa từ chúng.
Dennis

1
@Dennis nói rõ ràng, nó thực sự Tasklà thứ được phơi bày, không phải TaskCompletionSource. Điều đó (tiết lộ một API khác) về mặt kỹ thuật là một lựa chọn, nhưng nó là một điều khá cực đoan nếu làm chỉ vì điều này ... Tôi không chắc nó có thể biện minh cho điều đó
Marc Gravell

2
@MattH không thực sự - nó chỉ diễn đạt lại câu hỏi: hoặc bạn sử dụng ThreadPoolcho điều này (mà tôi đã đề cập - nó gây ra sự cố) hoặc bạn có một chuỗi "liên tục đang chờ xử lý" chuyên dụng, và sau đó họ (các hợp đồng với ExecuteSynchronouslychỉ định) có thể chiếm quyền điều khiển thay vào đó - điều này gây ra chính xác cùng một vấn đề, bởi vì nó có nghĩa là việc tiếp tục cho các tin nhắn khác có thể bị đình trệ, điều này lại ảnh hưởng đến nhiều người gọi
Marc Gravell

3
@Andrey rằng (nó hoạt động như thể tất cả những người gọi đều sử dụng ContinueWith mà không cần thực thi đồng bộ hóa) chính xác là những gì tôi muốn đạt được. Vấn đề là nếu thư viện của tôi giao cho ai đó một Nhiệm vụ, họ có thể làm điều gì đó rất không mong muốn: họ có thể làm gián đoạn trình đọc của tôi bằng cách (không thể nhìn thấy được) sử dụng đồng bộ hóa thực thi. Điều này cực kỳ nguy hiểm, đó là lý do tại sao tôi muốn ngăn chặn nó từ bên trong thư viện .
Marc Gravell

2
@Andrey bởi vì a: rất nhiều nhiệm vụ không bao giờ có được sự liên tục ngay từ đầu (đặc biệt là khi thực hiện công việc hàng loạt) - điều này sẽ buộc mọi nhiệm vụ phải có một và b: ngay cả những tác vụ đáng lẽ phải tiếp tục bây giờ cũng phức tạp hơn nhiều, chi phí cao và hoạt động của công nhân. Vấn đề này.
Marc Gravell

Câu trả lời:


50

Mới trong .NET 4.6:

NET 4.6 chứa một mới TaskCreationOptions: RunContinuationsAsynchronously.


Vì bạn sẵn sàng sử dụng Reflection để truy cập các trường riêng tư ...

Bạn có thể đánh dấu Nhiệm vụ của TCS bằng TASK_STATE_THREAD_WAS_ABORTEDcờ, điều này sẽ làm cho tất cả các phần tiếp theo không được liên kết.

const int TASK_STATE_THREAD_WAS_ABORTED = 134217728;

var stateField = typeof(Task).GetField("m_stateFlags", BindingFlags.NonPublic | BindingFlags.Instance);
stateField.SetValue(task, (int) stateField.GetValue(task) | TASK_STATE_THREAD_WAS_ABORTED);

Biên tập:

Thay vì sử dụng Phản xạ phát ra, tôi khuyên bạn nên sử dụng các biểu thức. Điều này dễ đọc hơn nhiều và có lợi thế là tương thích với PCL:

var taskParameter = Expression.Parameter(typeof (Task));
const string stateFlagsFieldName = "m_stateFlags";
var setter =
    Expression.Lambda<Action<Task>>(
        Expression.Assign(Expression.Field(taskParameter, stateFlagsFieldName),
            Expression.Or(Expression.Field(taskParameter, stateFlagsFieldName),
                Expression.Constant(TASK_STATE_THREAD_WAS_ABORTED))), taskParameter).Compile();

Nếu không sử dụng Phản chiếu:

Nếu có ai quan tâm, tôi đã tìm ra cách để thực hiện việc này mà không cần Reflection, nhưng nó cũng hơi "bẩn" và tất nhiên sẽ bị phạt không đáng kể:

try
{
    Thread.CurrentThread.Abort();
}
catch (ThreadAbortException)
{
    source.TrySetResult(123);
    Thread.ResetAbort();
}

3
@MarcGravell Sử dụng công cụ này để tạo một số mẫu giả cho nhóm TPL và thực hiện yêu cầu thay đổi về khả năng thực hiện điều này thông qua các tùy chọn phương thức khởi tạo hoặc thứ gì đó.
Adam Houldsworth

1
@Adam vâng, nếu bạn phải gọi cờ này là "nó làm gì" thay vì "nguyên nhân gây ra nó", thì nó sẽ giống như TaskCreationOptions.DoNotInline- và thậm chí sẽ không cần thay đổi chữ ký ctor thànhTaskCompletionSource
Marc Gravell

2
@AdamHouldsworth và đừng lo lắng, tôi cũng đang gửi email cho họ rồi; p
Marc Gravell

1
Đối với sự quan tâm của bạn: đây là nó, được tối ưu hóa qua ILGeneratorvv: github.com/StackExchange/StackExchange.Redis/blob/master/…
Marc Gravell

1
@Noseratio yup, đã kiểm tra chúng - cảm ơn; tất cả đều OK IMO; Tôi đồng ý rằng đây là cách giải quyết đơn thuần, nhưng nó có kết quả chính xác.
Marc Gravell

9

Tôi không nghĩ rằng có bất kỳ thứ gì trong TPL sẽ cung cấp khả năng kiểm soát API rõ ràng đối với các hoạt động TaskCompletionSource.SetResultliên tục. Tôi quyết định giữ câu trả lời ban đầu của mình để kiểm soát hành vi này cho các async/awaittình huống.

Đây là một giải pháp khác áp đặt không đồng bộ theo ContinueWith, nếu tcs.SetResulttiếp tục -triggered diễn ra trên cùng một chuỗi mà SetResultđã được gọi trên:

public static class TaskExt
{
    static readonly ConcurrentDictionary<Task, Thread> s_tcsTasks =
        new ConcurrentDictionary<Task, Thread>();

    // SetResultAsync
    static public void SetResultAsync<TResult>(
        this TaskCompletionSource<TResult> @this,
        TResult result)
    {
        s_tcsTasks.TryAdd(@this.Task, Thread.CurrentThread);
        try
        {
            @this.SetResult(result);
        }
        finally
        {
            Thread thread;
            s_tcsTasks.TryRemove(@this.Task, out thread);
        }
    }

    // ContinueWithAsync, TODO: more overrides
    static public Task ContinueWithAsync<TResult>(
        this Task<TResult> @this,
        Action<Task<TResult>> action,
        TaskContinuationOptions continuationOptions = TaskContinuationOptions.None)
    {
        return @this.ContinueWith((Func<Task<TResult>, Task>)(t =>
        {
            Thread thread = null;
            s_tcsTasks.TryGetValue(t, out thread);
            if (Thread.CurrentThread == thread)
            {
                // same thread which called SetResultAsync, avoid potential deadlocks

                // using thread pool
                return Task.Run(() => action(t));

                // not using thread pool (TaskCreationOptions.LongRunning creates a normal thread)
                // return Task.Factory.StartNew(() => action(t), TaskCreationOptions.LongRunning);
            }
            else
            {
                // continue on the same thread
                var task = new Task(() => action(t));
                task.RunSynchronously();
                return Task.FromResult(task);
            }
        }), continuationOptions).Unwrap();
    }
}

Đã cập nhật để giải quyết nhận xét:

Tôi không kiểm soát người gọi - tôi không thể khiến họ sử dụng một biến thể tiếp tục cụ thể: nếu tôi có thể, vấn đề sẽ không tồn tại ngay từ đầu

Tôi không biết rằng bạn không kiểm soát người gọi. Tuy nhiên, nếu bạn không kiểm soát nó, có thể bạn cũng không chuyển TaskCompletionSourceđối tượng trực tiếp đến người gọi. Về mặt logic, bạn đang chuyển phần mã thông báo của nó, tức là tcs.Task. Trong trường hợp đó, giải pháp có thể dễ dàng hơn bằng cách thêm một phương thức mở rộng khác vào phần trên:

// ImposeAsync, TODO: more overrides
static public Task<TResult> ImposeAsync<TResult>(this Task<TResult> @this)
{
    return @this.ContinueWith(new Func<Task<TResult>, Task<TResult>>(antecedent =>
    {
        Thread thread = null;
        s_tcsTasks.TryGetValue(antecedent, out thread);
        if (Thread.CurrentThread == thread)
        {
            // continue on a pool thread
            return antecedent.ContinueWith(t => t, 
                TaskContinuationOptions.None).Unwrap();
        }
        else
        {
            return antecedent;
        }
    }), TaskContinuationOptions.ExecuteSynchronously).Unwrap();
}

Sử dụng:

// library code
var source = new TaskCompletionSource<int>();
var task = source.Task.ImposeAsync();
// ... 

// client code
task.ContinueWith(delegate
{
    Identify();
}, TaskContinuationOptions.ExecuteSynchronously);

// ...
// library code
source.SetResultAsync(123);

Điều này thực sự hoạt động cho cả awaitContinueWith ( fiddle ) và không có hack phản chiếu.


1
Tôi không kiểm soát người gọi - tôi không thể yêu cầu họ sử dụng một biến thể tiếp tục cụ thể: nếu tôi có thể, vấn đề sẽ không tồn tại ngay từ đầu
Marc Gravell

@MarcGravell, tôi không biết rằng bạn không thể kiểm soát người gọi. Tôi đã đăng một bản cập nhật về cách tôi sẽ đối phó với nó.
mũi,

tình thế tiến thoái lưỡng nan của tác giả thư viện; p Lưu ý rằng ai đó đã tìm ra cách đơn giản và trực tiếp hơn nhiều để đạt được kết quả mong muốn
Marc Gravell

4

Thay vì làm thì sao

var task = source.Task;

bạn làm điều này thay thế

var task = source.Task.ContinueWith<Int32>( x => x.Result );

Do đó, bạn luôn thêm một phần tiếp theo sẽ được thực thi không đồng bộ và sau đó không quan trọng nếu người đăng ký muốn một phần tiếp theo trong cùng một ngữ cảnh. Nó giống như một nhiệm vụ, phải không?


1
Điều đó xuất hiện trong các bình luận (xem Andrey); vấn đề ở đây là nó buộc tất cả các nhiệm vụ phải tiếp tục khi chúng không có, đó là điều mà cả hai ContinueWithawaitthường cố gắng tránh (bằng cách kiểm tra xem đã hoàn thành, v.v.) - và vì điều này sẽ buộc mọi thứ vào công nhân, nó thực sự sẽ làm trầm trọng thêm tình hình. Đó là một ý tưởng tích cực, và tôi cảm ơn bạn vì nó: nhưng nó sẽ không giúp ích gì trong trường hợp này.
Marc Gravell

3

nếu bạn có thể và sẵn sàng sử dụng phản xạ, điều này nên làm điều đó;

public static class MakeItAsync
{
    static public void TrySetAsync<T>(this TaskCompletionSource<T> source, T result)
    {
        var continuation = typeof(Task).GetField("m_continuationObject", BindingFlags.NonPublic | BindingFlags.GetField | BindingFlags.Instance);
        var continuations = (List<object>)continuation.GetValue(source.Task);

        foreach (object c in continuations)
        {
            var option = c.GetType().GetField("m_options", BindingFlags.NonPublic | BindingFlags.GetField | BindingFlags.Instance);
            var options = (TaskContinuationOptions)option.GetValue(c);

            options &= ~TaskContinuationOptions.ExecuteSynchronously;
            option.SetValue(c, options);
        }

        source.TrySetResult(result);
    }        
}

Bản hack này có thể chỉ ngừng hoạt động trong phiên bản tiếp theo của Framework.
mũi,

@Noseratio, true nhưng nó hoạt động ngay bây giờ và họ cũng có thể triển khai một cách thích hợp để thực hiện điều này trong phiên bản tiếp theo
Fredou

Nhưng tại sao bạn cần điều này nếu bạn đơn giản có thể làm được Task.Run(() => tcs.SetResult(result))?
mũi,

@Noseratio, tôi không biết, hãy đặt câu hỏi đó cho Marc :-), tôi chỉ đơn giản là xóa cờ TaskContinuationOptions.ExecuteSynchronously trên tất cả các tác vụ được kết nối với TaskCompletionSource để đảm bảo tất cả họ đều sử dụng threadpool thay vì chuỗi chính
Fredou

Vụ hack m_continuationObject thực sự là trò gian lận mà tôi đã sử dụng để xác định các nhiệm vụ có thể có vấn đề - vì vậy điều này không nằm ngoài sự cân nhắc. Thú vị, cảm ơn. Đây là tùy chọn hữu ích nhất cho đến nay.
Marc Gravell

3

Đã cập nhật , tôi đã đăng một câu trả lời riêng để giải quyết ContinueWithtrái ngược với await(vì ContinueWithkhông quan tâm đến bối cảnh đồng bộ hóa hiện tại).

Bạn có thể sử dụng một bối cảnh đồng bộ hóa câm để áp đặt sự không đồng bộ khi tiếp tục kích hoạt bằng cách gọi SetResult/SetCancelled/SetExceptionvào TaskCompletionSource. Tôi tin rằng bối cảnh đồng bộ hóa hiện tại (tại điểm await tcs.Task) là tiêu chí TPL sử dụng để quyết định xem việc tiếp tục như vậy đồng bộ hay không đồng bộ.

Những điều sau đây phù hợp với tôi:

if (notifyAsync)
{
    tcs.SetResultAsync(null);
}
else
{
    tcs.SetResult(null);
}

SetResultAsync được triển khai như thế này:

public static class TaskExt
{
    static public void SetResultAsync<T>(this TaskCompletionSource<T> tcs, T result)
    {
        FakeSynchronizationContext.Execute(() => tcs.SetResult(result));
    }

    // FakeSynchronizationContext
    class FakeSynchronizationContext : SynchronizationContext
    {
        private static readonly ThreadLocal<FakeSynchronizationContext> s_context =
            new ThreadLocal<FakeSynchronizationContext>(() => new FakeSynchronizationContext());

        private FakeSynchronizationContext() { }

        public static FakeSynchronizationContext Instance { get { return s_context.Value; } }

        public static void Execute(Action action)
        {
            var savedContext = SynchronizationContext.Current;
            SynchronizationContext.SetSynchronizationContext(FakeSynchronizationContext.Instance);
            try
            {
                action();
            }
            finally
            {
                SynchronizationContext.SetSynchronizationContext(savedContext);
            }
        }

        // SynchronizationContext methods

        public override SynchronizationContext CreateCopy()
        {
            return this;
        }

        public override void OperationStarted()
        {
            throw new NotImplementedException("OperationStarted");
        }

        public override void OperationCompleted()
        {
            throw new NotImplementedException("OperationCompleted");
        }

        public override void Post(SendOrPostCallback d, object state)
        {
            throw new NotImplementedException("Post");
        }

        public override void Send(SendOrPostCallback d, object state)
        {
            throw new NotImplementedException("Send");
        }
    }
}

SynchronizationContext.SetSynchronizationContext là rất rẻ về chi phí mà nó thêm vào. Trên thực tế, một cách tiếp cận rất tương tự được thực hiện bằng cách triển khai WPFDispatcher.BeginInvoke .

TPL so sánh ngữ cảnh đồng bộ hóa đích tại điểm awaitđến với bối cảnh của điểm tcs.SetResult. Nếu bối cảnh đồng bộ hóa giống nhau (hoặc không có bối cảnh đồng bộ hóa ở cả hai nơi), thì sự tiếp tục được gọi là trực tiếp, đồng bộ. Nếu không, nó được xếp hàng đợi bằng cách sử dụng SynchronizationContext.Postngữ cảnh đồng bộ hóa đích, tức là awaithành vi bình thường . Những gì cách tiếp cận này làm là luôn áp đặt SynchronizationContext.Posthành vi (hoặc tiếp tục chuỗi nhóm nếu không có ngữ cảnh đồng bộ hóa đích).

Đã cập nhật , điều này sẽ không hoạt động task.ContinueWithContinueWithkhông quan tâm đến bối cảnh đồng bộ hóa hiện tại. Tuy nhiên, nó hoạt động cho await task( fiddle ). Nó cũng hoạt động cho await task.ConfigureAwait(false).

OTOH, cách tiếp cận này hoạt động cho ContinueWith.


Hấp dẫn, nhưng việc thay đổi ngữ cảnh đồng bộ hóa gần như chắc chắn sẽ ảnh hưởng đến ứng dụng gọi điện - ví dụ: ứng dụng web hoặc Windows chỉ tình cờ sử dụng thư viện của tôi sẽ không thấy ngữ cảnh đồng bộ hóa thay đổi hàng trăm lần mỗi giây.
Marc Gravell

@MarcGravell, tôi chỉ thay đổi nó cho phạm vi tcs.SetResultcuộc gọi. Nó kinda trở thành nguyên tử và thread-safe cách này, vì việc tiếp tục tự nó sẽ xảy ra trên hoặc một thread hồ bơi hoặc trên đồng bộ ban đầu. bối cảnh được chụp tại await tcs.Task. Và SynchronizationContext.SetSynchronizationContextbản thân nó rất rẻ, rẻ hơn nhiều so với bản thân công tắc luồng.
mũi,

Tuy nhiên, điều này có thể không đáp ứng yêu cầu thứ hai của bạn: không sử dụng ThreadPool. Với giải pháp này, TPL sẽ thực sự sử dụng ThreadPool, nếu không có sự đồng bộ. ngữ cảnh (hoặc đó là bối cảnh mặc định cơ bản) tại await tcs.Task. Nhưng đây là hành vi TPL tiêu chuẩn.
mũi,

Hmmm ... vì ngữ cảnh đồng bộ là mỗi luồng, điều này thực sự có thể khả thi - và tôi sẽ không cần phải tiếp tục chuyển đổi ctx - chỉ cần đặt nó một lần cho chuỗi công nhân. Tôi sẽ cần phải chơi với nó
Marc Gravell

1
@Noseration ah, đúng: không rõ điểm mấu chốt là chúng khác nhau . Sẽ xem xét. Cảm ơn.
Marc Gravell

3

Phương pháp hủy bỏ mô phỏng trông thực sự tốt, nhưng đã dẫn đến các luồng chiếm đoạt TPL trong một số trường hợp .

Sau đó, tôi đã có một triển khai tương tự như kiểm tra đối tượng tiếp tục , nhưng chỉ kiểm tra bất kỳ tiếp tục nào vì thực sự có quá nhiều kịch bản để mã đã cho hoạt động tốt, nhưng điều đó có nghĩa là ngay cả những thứ như vậy cũng Task.Waitdẫn đến tra cứu nhóm luồng.

Cuối cùng, sau khi kiểm tra nhiều và rất nhiều IL, kịch bản an toàn và hữu ích duy nhất là SetOnInvokeMreskịch bản (tiếp tục thủ công-đặt lại-sự kiện-mỏng). Có rất nhiều kịch bản khác:

  • một số không an toàn và dẫn đến cướp chuỗi
  • phần còn lại không hữu ích, vì cuối cùng chúng dẫn đến nhóm luồng

Vì vậy, cuối cùng, tôi đã chọn kiểm tra một đối tượng tiếp tục không null; nếu nó là null, tốt (không có liên tục); nếu nó không phải là null, hãy kiểm tra trường hợp đặc biệt SetOnInvokeMres- nếu nó là: fine (an toàn để gọi); nếu không, hãy để thread-pool thực hiện TrySetCompletemà không yêu cầu nhiệm vụ làm bất cứ điều gì đặc biệt như hủy bỏ giả mạo. Task.Waitsử dụng SetOnInvokeMrescách tiếp cận, đó là kịch bản cụ thể mà chúng tôi muốn thực sự cố gắng để không bị bế tắc.

Type taskType = typeof(Task);
FieldInfo continuationField = taskType.GetField("m_continuationObject", BindingFlags.Instance | BindingFlags.NonPublic);
Type safeScenario = taskType.GetNestedType("SetOnInvokeMres", BindingFlags.NonPublic);
if (continuationField != null && continuationField.FieldType == typeof(object) && safeScenario != null)
{
    var method = new DynamicMethod("IsSyncSafe", typeof(bool), new[] { typeof(Task) }, typeof(Task), true);
    var il = method.GetILGenerator();
    var hasContinuation = il.DefineLabel();
    il.Emit(OpCodes.Ldarg_0);
    il.Emit(OpCodes.Ldfld, continuationField);
    Label nonNull = il.DefineLabel(), goodReturn = il.DefineLabel();
    // check if null
    il.Emit(OpCodes.Brtrue_S, nonNull);
    il.MarkLabel(goodReturn);
    il.Emit(OpCodes.Ldc_I4_1);
    il.Emit(OpCodes.Ret);

    // check if is a SetOnInvokeMres - if so, we're OK
    il.MarkLabel(nonNull);
    il.Emit(OpCodes.Ldarg_0);
    il.Emit(OpCodes.Ldfld, continuationField);
    il.Emit(OpCodes.Isinst, safeScenario);
    il.Emit(OpCodes.Brtrue_S, goodReturn);

    il.Emit(OpCodes.Ldc_I4_0);
    il.Emit(OpCodes.Ret);

    IsSyncSafe = (Func<Task, bool>)method.CreateDelegate(typeof(Func<Task, bool>));
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.