Skip to content

Can the BlockingCollection<T>.GetConsumingEnumerable iterator be optimized? #69320

Description

@theodorzoulias

Hi! I noticed that the implementation of the BlockingCollection<T>.GetConsumingEnumerable method checks twice if the collection IsCompleted on each iteration. It is checked in the while loop:

while (!IsCompleted)
{
    T? item;
    if (TryTakeWithNoTimeValidation(out item, Timeout.Infinite, cancellationToken, linkedTokenSource))
    {
        yield return item;
    }
}

...and also inside the TryTakeWithNoTimeValidation method:

if (IsCompleted)
{
    return false;
}

I would like to ask if there is a technical reason for this double check, or if it's something that could be optimized. Like this for example:

while (true)
{
    T? item;
    if (TryTakeWithNoTimeValidation(out item, Timeout.Infinite, cancellationToken, linkedTokenSource))
    {
        yield return item;
    }
    else
    {
        break;
    }
}

Thanks!

Activity

  1. ghost added
    untriagedNew issue has not been triaged by the area owner
    on May 13, 2022
  2. ghost removed
    untriagedNew issue has not been triaged by the area owner
    on May 13, 2022
  3. eiriktsarpalis commented on May 20, 2022

    @eiriktsarpalis
    Member

    Is the TryTakeWithNoTimeValidation method invoked elsewhere? If not, the check might exist for historical reasons that no longer apply, and replacing it with a Debug.Assert(IsCompleted); assertion is a good idea. Would you be interested in prototyping such an improvement?

  4. added
    enhancementProduct code improvement that does NOT require public API changes/additions
    and removed
    untriagedNew issue has not been triaged by the area owner
    on May 20, 2022
  5. added this to the Future milestone on May 20, 2022
  6. added
    help wanted[up-for-grabs] Good issue for external contributors
    wishlistIssue we would like to prioritize, but we can't commit we will get to it yet
    on May 20, 2022
  7. theodorzoulias commented on May 20, 2022

    @theodorzoulias
    ContributorAuthor

    @eiriktsarpalis yes, the TryTakeWithNoTimeValidation is also used by these three methods:

    public bool TryTake(out T item, TimeSpan timeout);
    public bool TryTake(out T item, int millisecondsTimeout);
    public bool TryTake(out T item, int millisecondsTimeout, CancellationToken cancellationToken);

    The check is needed for these methods. Removing it would most likely cause performance regression. It's just that the GetConsumingEnumerable iterator does also this check just before calling this method, so it seems like the check in the iterator while (!IsCompleted) is superfluous.

    Regarding prototyping an improvement, I am not aware of the procedure. Is there any document that I could read about it?

  8. eiriktsarpalis commented on May 20, 2022

    @eiriktsarpalis
    Member

    Take a look at the workflow guide to get started on building and testing the repo.

  9. theodorzoulias commented on May 20, 2022

    @theodorzoulias
    ContributorAuthor

    @eiriktsarpalis thanks for the link. I am seeing that building the repository is a quite involved process, so I'll skip it for now. Feel free to close this issue if you think that it's not actionable. The impact of removing the superfluous check of a volatile field should be quite minuscule after all. 😃

  10. ghost added
    in-prThere is an active PR which will close this issue when it is merged
    on May 20, 2022
  11. theodorzoulias commented on May 21, 2022

    @theodorzoulias
    ContributorAuthor

    I think that I found the answer why the "superfluous" second check exists. In the case of a completed BlockingCollection<T>, the GetConsumingEnumerable iterator has different behavior than the Take/TryTake methods when cancellation is involved. The iterator ignores the cancellation and completes normally, while the Take/TryTake honor the cancellation and complete with exception. Here is a minimal demonstration of this behavior:

    var blockingCollection = new BlockingCollection<object>();
    blockingCollection.CompleteAdding();
    var token = new CancellationToken(true); // Canceled
    
    Test("Take", () => blockingCollection.Take(token));
    Test("TryTake", () => blockingCollection.TryTake(out _, Timeout.Infinite, token));
    Test("GetConsumingEnumerable", () =>
    {
        foreach (var item in blockingCollection.GetConsumingEnumerable(token)) { }
    });
    
    static void Test(string title, Action action)
    {
        try { action(); Console.WriteLine($"{title}, OK!"); }
        catch (Exception ex) { Console.WriteLine($"{title}, Error: {ex.GetType().Name}"); }
    }

    Output:

    Take, Error: OperationCanceledException
    TryTake, Error: OperationCanceledException
    GetConsumingEnumerable, OK!
    

    Live demo.

    So the optimization that I suggested is problematic, because it results in a behavioral change: The iterator will start throwing exceptions in conditions that previously didn't.

    I am not closing this issue, because it might be possible to eliminate the double check without altering the current behavior.

  12. eiriktsarpalis commented on May 21, 2022

    @eiriktsarpalis
    Member

    I am not closing this issue, because it might be possible to eliminate the double check without altering the current behavior.

    I guess my question would be, why is it important to eliminate the double check? Is it demonstrably impacting performance? I wouldn't think so, this is a concurrent collection so I find it unlikely that a redundant check would be a performance bottleneck.

  13. theodorzoulias commented on May 21, 2022

    @theodorzoulias
    ContributorAuthor

    @eiriktsarpalis the BlockingCollection<T>.IsCompleted property (source code) calls the SemaphoreSlim.CurrentCount property (source code), which is backed by a volatile field. My understanding is that accessing a volatile field results in emitting a half fence, which is not something completely trivial. From what I know it amounts to a few dozens CPU cycles, but I might be wrong.

  14. ghost removed
    in-prThere is an active PR which will close this issue when it is merged
    on Jun 13, 2022
  15. ghost added
    in-prThere is an active PR which will close this issue when it is merged
    on Nov 27, 2022
  16. ghost removed
    in-prThere is an active PR which will close this issue when it is merged
    on Jan 10, 2023
  17. ghost locked as resolved and limited conversation to collaborators on Feb 9, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-System.CollectionsenhancementProduct code improvement that does NOT require public API changes/additionshelp wanted[up-for-grabs] Good issue for external contributorswishlistIssue we would like to prioritize, but we can't commit we will get to it yet

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions