Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

proposal: sync: Map should provide DeleteWithLoad which returns loaded value and boolean #33762

Open
wkonkel opened this issue Aug 21, 2019 · 8 comments

Comments

@wkonkel
Copy link

commented Aug 21, 2019

Problem

It is not currently possible to determine if a value was deleted from a sync.Map. For example:

actual, ok := m.Load("key")
if ok {
  m.Delete("key")
  doSomething(actual)
}

This is not atomic in that another goroutine could Delete the value between Load and Delete and doSomething() would be called twice.

Previous issues #25396 and #23547 suggested modifying Delete signature which would break backwards compatibility.

Solution

Create a new function:

func DeleteWithLoad(key interface{}) (actual interface{}, deleted bool)

If the key is found in the map, this function would return the deleted value and true. If the value is not found, this would return nil, false. The above code would then become:

actual, deleted := m.DeleteWithLoad("key")
if deleted {
  doSomething(actual)
}

@gopherbot gopherbot added this to the Proposal milestone Aug 21, 2019

@gopherbot gopherbot added the Proposal label Aug 21, 2019

@ianlancetaylor ianlancetaylor changed the title proposal: sync.Map should provide DeleteWithLoad which returns loaded value and boolean proposal: sync: Map should provide DeleteWithLoad which returns loaded value and boolean Aug 21, 2019

@ianlancetaylor

This comment has been minimized.

Copy link
Contributor

commented Aug 21, 2019

@bcmills

This comment has been minimized.

Copy link
Member

commented Aug 22, 2019

On a cosmetic note, I would want to name the method Remove rather than DeleteWithLoad.

@bcmills

This comment has been minimized.

Copy link
Member

commented Aug 22, 2019

I'm curious what the concrete use-cases are. (The proposed operation seems useful in theory, but I wonder whether it actually is in practice.)

Could you give some examples of existing code using sync.Map or a similar API that would benefit from this API?

@wkonkel

This comment has been minimized.

Copy link
Author

commented Aug 22, 2019

A practical example would be using sync.Map as an in-memory cache above a remote database. Since database insert/delete operations are relatively expensive, we want to only call them as needed. The LoadOrStore function allows us to perform a single insert operation even if many goroutines are trying to insert the same data. But there is no equivalent way to ensure a single operation for delete.

func (self *Cache) Add(key interface{}, value interface{}) {
  _, loaded := self.syncMap.LoadOrStore(key, value)
  if !loaded {
    self.database.Insert(key, value) // expensive operation
  }
}

func (self *Cache) Remove(key interface{}) {
  _, removed := self.syncMap.Remove(key) // proposed function
  if removed {
    self.database.Delete(key) // expensive operation
  }
}
@bcmills

This comment has been minimized.

Copy link
Member

commented Aug 22, 2019

That example seems racy, though: nothing in sync.Map serializes the calls to Insert and Delete, so you could end up with an execution trace that looks like:

  • LoadOrStoreloaded = false
  • Removeremoved = true
  • database.Delete(key) (a no-op)
  • database.Insert(key, value)

Now your cache is out of sync with the underlying database: the cache contains no mapping for key, but the underlying database maps it to value.

I suspect that you'll find this sort of insert/delete race in most concrete examples, and the fix is generally to add some other exclusion mechanism (for example, a sync.Mutex) on the slow path, and to recheck the sync.Map once more once the exclusive lock has been acquired. In that case, the Load and Delete operations on the map no longer need to be atomic (since they're synchronized externally).

@wkonkel

This comment has been minimized.

Copy link
Author

commented Aug 22, 2019

By that logic, shouldn't LoadOrStore be removed since it enables similar behavior if 2 goroutines ran in parallel:

  • [1] LoadOrStoreloaded = false
  • [2] Delete
  • [1] if loaded { ... }

By the time if loaded { ... } is evaluated, the underlying sync.Map has changed.

Ultimately, I don't think there is an expectation that the loaded return value from LoadOrStore represents the current state of the Map but rather just what happened when LoadOrStore was called. Similarly, the removed return value from the proposed Remove would have the same expected behavior. It doesn't represent current state but rather the result of the function call. If a guarantee of current state is needed for either LoadOrStore or Remove, then sync.Mutex is the only approach.

In my specific use case, deletes happen substantially later than adds, so there is never Store-Delete contention. Instead, I'm trying to prevent hundreds of goroutines from triggering the same Add or Remove query from running. I currently use LoadOrStore to solve for the contentious Add use case, but am forced to use sync.Mutex to solve for contentious Delete.

@ianlancetaylor

This comment has been minimized.

Copy link
Contributor

commented Sep 3, 2019

Ping @bcmills

@bcmills

This comment has been minimized.

Copy link
Member

commented Sep 11, 2019

Ok, I think I can see what you mean. For example, you may have a wave of Store calls, followed by some external synchronization point, followed by a wave of Remove calls, and at that point you're guaranteed that none of the Removes races with a Store.

In that case, I'd be ok with a new method with a signature along the lines of:

func (m *Map) Remove(key interface{}) (prevValue interface{}, removed bool)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
4 participants
You can’t perform that action at this time.