Library / SDK
puzpuzpuz/xsync avatar
puzpuzpuz/xsync

xsync replaces sync.Map with a cache-line hash table

Concurrent data structures for Go

1,725 stars74 forksGoApache-2.0

At a glance

What is it?
xsync is an Apache-2.0 licensed Go library of concurrent data structures whose Map is built on a modified Cache-Line Hash Table, giving lock-free reads and cooperative parallel rehashing, alongside a striped counter, lock-free queues and an RWMutex variant. It requires Go 1.24 and is on the v4 module path.
Who is it for?
xsync fits a Go service with a genuinely contended shared map, where sync.Map's read path or its write contention is measurable in a profile, and it fits a team that wants a counter or a lock-free queue without pulling a heavier dependency. It does not fit an application whose map is not contended, because the standard library is faster there and simpler, and it does not fit a team that cannot move to Go 1.24 or the v4 module path.
Can I use it commercially?
Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 26 days ago.
What is it written in?
Mainly Go, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The Map is a modified CLHT, and that is the whole point

xsync is a library of concurrent data structures for Go that aims to provide more scalable alternatives for some of the data structures from the standard sync package, but not only.

The Map is the centrepiece. A Map is like a concurrent hash table-based map, and it follows the interface of sync.Map with a number of valuable extensions like Compute or Size.

go
m := xsync.NewMap[string, string]()
m.Store("foo", "bar")
v, ok := m.Load("foo")
s := m.Size()

The data structure underneath is a modified version of Cache-Line Hash Table, the CLHT from the EPFL Laboratory for Parallelism and Distributed Systems. The README explains why that matters in terms a systems programmer will recognise.

CLHT is built around the idea of organizing the hash table in cache-line-sized buckets, so that on all modern CPUs update operations complete with minimal cache-line transfer. Also, Get operations are obstruction-free and involve no writes to shared memory, hence no mutexes or any other sort of locks. Due to this design, in all considered scenarios Map outperforms sync.Map.

That is the argument in one paragraph, and it is a specific one. sync.Map is optimised for the read-mostly case with a dirty map and read-only promotion, which is a great design for that case and a poor one when writes are frequent. CLHT instead spreads the map across cache lines so that concurrent writers touch different memory, which is the opposite trade and the right one for a contended workload.

Two further ideas are borrowed. The Map borrows from Java's j.u.c.ConcurrentHashMap, with immutable K/V pair structs instead of atomic snapshots, and from C++'s absl::flat_hash_map, a.k.a. SwissTable, for the meta memory and SWAR-based lookups. So the implementation is a synthesis rather than a port, and the SWAR detail is worth knowing, since it is how a lookup examines several key bytes at once rather than comparing them one at a time.

The Map also uses the built-in Golang hash function which has DDOS protection, and maphash.Comparable as the default hash function, which means each map instance gets its own seed number and the hash function uses that seed for hash code calculation. A per-instance seed is what makes the hash flood resistance useful rather than theoretical.

Rehashing happens in the writers' own goroutines

The second structural idea is cooperative parallel rehashing, and the README is clear that it is not a background job.

Map also uses cooperative parallel rehashing: this means that the goroutines executing write operations may participate in a concurrent rehashing instead of waiting for it to finish.

This is a design decision with a clear motivation. A map that has outgrown its buckets has to grow, and most implementations do that in one of two ways, a stop-the-world resize under a write lock or a background goroutine that does the resize while readers continue against the old table. The background approach costs a goroutine and a handoff, and the writers still wait when the resize finishes. The cooperative approach makes the writers do the work, and a writer that is already inside the map is a writer that is already paying a cache miss for the bucket it is touching.

So the cost of a resize is spread across the goroutines that would have paid it anyway, and no goroutine has to wait for a resize it did not trigger. The cost is that the write path becomes more complex, since a writer must be able to participate in moving a segment while holding whatever per-segment state it needs, and the correctness of that is the sort of thing that the stress test coverage in this project exists to check.

That testing is the other thing the README points at, and it is a specific claim. The data structures are covered with concurrent stress tests following a described approach, and the article it links to is about testing concurrent code for fun and profit. That is a stronger statement than having some tests. A concurrent data structure that passes a stress test with a particular interleaving schedule has not been proven correct, and the honest position is that a large number of randomly interleaved runs is a much better filter than none.

The README also notes that the data structures are met in code in other libraries, pointing at the Otter caching library as an example of an in-code user of xsync. That is useful context, because it means the structures are exercised by a caching library's production use and not only by their own tests.

Iteration comes in two consistencies

The Map offers two iteration modes and the difference is a consistency guarantee, not a speed setting.

Besides the Range and All methods available for map iteration, there is also a ToPlainMap utility function to convert a Map to a built-in Go's map.

go
m := xsync.NewMap[int, int]()
m.Store(42, 42)
pm := xsync.ToPlainMap(m)

And for high-performance iteration, RangeRelaxed, and AllRelaxed for Go 1.23+ iterators, can be used. Unlike Range, it is lock-free. However, it has relaxed consistency: the same key may be visited more than once if it is concurrently deleted and re-inserted during the iteration.

go
m.RangeRelaxed(func(key int, value int) bool {
	// process entry
	return true // continue iteration
})

So the trade is explicit. A normal Range is safe, in the sense that it will not hand you the same key twice, and it costs locks. A relaxed Range is lock-free and faster, and it can hand you the same key twice if that key is deleted and reinserted while the iteration is running.

That is a subtle enough behaviour to be worth stating carefully. A duplicate visit is only a problem for code that assumes it sees each entry once, which is code doing aggregation, accounting or anything that counts. For code that is idempotent per entry, such as refreshing a cache entry or writing to a log, a duplicate is harmless.

The bulk deletion helper has the same flavour of deliberate looseness. For bulk conditional deletions, DeleteMatching can be used, and the method is handy in caching use cases when it is necessary to delete stale entries.

go
m.DeleteMatching(func(key int, value int) (delete, stop bool) {
	return key%2 == 0, false // delete even keys
})

The two return values are the delete decision and a stop flag, so the callback can both choose what to remove and halt the sweep early.

A counter, three queues and a lock

The rest of the library is a set of focused structures, and each one names its inspiration.

A Counter is a striped int64 counter inspired by the j.u.c.a.LongAdder class from the Java standard library.

go
c := xsync.NewCounter()
c.Inc()
c.Dec()
v := c.Value()

The stated benefit is that it works better in comparison with a single atomically updated int64 counter in high contention scenarios, which is the whole reason LongAdder exists in Java. Striping means each goroutine updates its own cell and the value is the sum, so the cost of contention is spread.

The queues are three different shapes for three different situations.

A UMPSCQueue is an unbounded multi-producer single-consumer concurrent queue, where multiple goroutines can publish items while not more than a single goroutine must be consuming those items. Unlike bounded queues, this one puts no limit to the queue capacity.

go
q := xsync.NewUMPSCQueue[string]()
inserted := q.Enqueue("bar")
item := q.Dequeue()

The README is unusually blunt about the consequences. It is meant to serve as a replacement for a channel, however, crucially, it has infinite capacity. This is a very bad idea in many cases as it means it never exhibits backpressure. In other words, if nothing is consuming elements from the queue, it will eventually consume all available memory and crash the process. However, there are also cases where this is desired behavior as it means the queue will dynamically allocate more memory to store temporary bursts, allowing producers to never block while the consumer catches up.

That is a genuine engineering trade, stated as one, and a Go developer who reads it will make the right call. An unbounded queue is a memory exhaustion bug waiting for a traffic spike unless you have measured that bursts are short and the consumer always catches up.

The implementation is described in enough detail to be interesting. The backing data structure is represented as a singly linked list of large segments, each segment a slice of T along with a corresponding sync.WaitGroup for each index. Producers use an atomic counter to determine the unique index in the segment where they will write their value, and mark the corresponding wait group as done after having written the value. The consumer keeps track of the index it wants to read and waits for the corresponding wait group to complete. Neither operation acquires a lock, so both perform quite well under highly contentious loads.

And the safety condition follows from the design. Because no locks are acquired, it is unsafe for multiple goroutines to consume from the queue, and consumers must explicitly synchronize between themselves. That is what allows a single consumer to never acquire a lock.

A SPSCQueue is the bounded single-producer single-consumer counterpart, and the repository listing shows the rest: mpmcqueue.go for the multi-producer multi-consumer queue, rbmutex.go for an RWMutex variant, and counter.go, map.go, spscqueue.go, umpscqueue.go and util.go alongside their test files.

Benchmarks are published, and one of them is explicitly unfair

The README's benchmark section is short and honest in a way that is worth reading.

Benchmark results may be found in BENCHMARKS.md, with a thank you to felixge who kindly ran the benchmarks on a beefy multicore machine.

And then the caveat, which is unusual to see stated. Also, a non-scientific, unfair benchmark comparing Java's j.u.c.ConcurrentHashMap and xsync.Map is available, at puzpuzpuz.dev/concurrent-map-in-go-vs-java-yet-another-meaningless-benchmark. The title itself calls it another meaningless benchmark.

Calling your own comparison unfair in the README, and putting a word like meaningless in the URL, is a sign of a maintainer who does not want the numbers to be quoted out of context. The obvious failure mode of a benchmark post is that a vendor publishes a number showing their map is faster than a competitor's, and a later reader treats it as a general claim. Refusing to make that claim is worth something.

The BENCHMARKS.md file is in the repository root and is the place to look for the real numbers, and the acknowledgement of who ran them matters too. A benchmark on a beefy multicore machine is a different machine from a small cloud instance, and a contention-heavy result on 64 cores says very little about a 4 core production box. The right way to use any of these numbers is to run them on your own hardware with your own access pattern, which is why the source being a normal Go module with a benchmarks file is itself the useful thing.

The repository top level listing shows the structure of the project, one file per structure with a matching test file, plus example_test.go for the documentation examples and export_test.go for the internal test hooks. There is also bucket-alignment-fix.md, a document about a specific problem, which suggests the cache-line bucket alignment has been a source of subtlety and that the reasoning is written down.

The release cadence is steady for a library at this level. v4.3.0 came out on 2026-01-12, v4.4.0 on 2026-01-24, v4.5.0 on 2026-04-18, and the last push to main was on 2026-09-27. The project is Apache-2.0 licensed with the GoDoc reference, a GoReport badge and a codecov badge in the header.

Where xsync is the wrong choice

The first wrong case is a map that is not contended. sync.Map is very good at what it optimises for, and if your map is read-mostly with occasional writes, the standard library will be at least as fast and considerably simpler, and every Go developer reading the code will already know its semantics. Swapping it out for a library with per-instance hash seeds, a different iteration consistency model and a different resize strategy is a large change for a workload that does not need it.

The second wrong case is a team that wants one library for everything. xsync is a set of specialised structures with different tradeoffs, and the unbounded queue in particular has a footgun in it that the README does not hide. Picking a structure per problem is a decision, and a project that adopts a package of five structures will use the wrong one somewhere.

The third wrong case is a codebase that cannot move to Go 1.24 or change its module path. The v4 line requires Go 1.24, and the import path carries a /v4 suffix, and the migration from v3 to v4 removed the non-generic data structures with the old *Of types kept as type aliases, so the migration should be trivial but it is still a migration.

The fourth wrong case is code that assumes each key is visited once during iteration. The relaxed iteration mode is a performance option, not a default, and adopting it in a codebase that aggregates or counts is a correctness bug waiting to happen.

How it differs from sync.Map

The comparison worth making is with sync.Map, since the README says the Map follows its interface and the whole point is to be better in some cases.

sync.Map is built around a read-only map and a dirty map. Reads consult the read-only map, which holds immutable interface values, and writes go to the dirty map, with an amortised promotion of the dirty map into the read-only one when misses become frequent enough. That design is excellent when reads vastly outnumber writes, because the read path is a plain map lookup with no atomic operation and no lock at all. It is poor when writes are frequent, because the dirty map needs a mutex, and the amortised promotion means the read path stops being free.

CLHT takes the opposite approach. The map is partitioned into cache-line-sized buckets, so two goroutines writing to different keys usually touch different cache lines and do not contend on the same memory word. A write is a compare and swap on a slot rather than a mutex acquisition, and a read is obstruction-free, involving no writes to shared memory, hence no mutexes or any other sort of locks. The result is that concurrent writers do not serialise, which is the case sync.Map handles badly.

The second difference is the resize. sync.Map handles growth by copying the dirty map into a new read-only map under the lock, which is a stop-the-world pause for the map. CLHT in this implementation uses cooperative parallel rehashing, where the goroutines performing write operations participate in the concurrent rehashing instead of waiting for it to finish, so the pause is spread across the writers.

The third difference is the interface. The Map follows sync.Map's interface but adds Compute and Size, which are the two operations people reach for and find missing. A Compute would be a read-modify-write under the map's own lock, and Size is a count of the entries, which sync.Map does not provide at all.

The fourth difference is the API surface, and it is the one to check before upgrading. The main change between v3 and v4 is the removal of non-generic data structures and some improvements in the Map API, with the old *Of types kept as type aliases for the renamed data structures to simplify the migration, so MapOf is an alias for Map. The API does have some breaking changes, and the migration should be trivial.

Editorial conclusion

xsync fits a Go service with a genuinely contended shared map, where sync.Map's read path or its write contention is measurable in a profile, and it fits a team that wants a counter or a lock-free queue without pulling a heavier dependency. It does not fit an application whose map is not contended, because the standard library is faster there and simpler, and it does not fit a team that cannot move to Go 1.24 or the v4 module path. Before adopting it, run the benchmarks in BENCHMARKS.md on your own hardware and your own access pattern, and read the DeleteMatching and RangeRelaxed consistency notes before using them, since RangeRelaxed may visit the same key twice and the unbounded UMPSCQueue never applies backpressure.

Frequently asked questions

What data structures does xsync provide?

A Counter, a striped int64 counter inspired by Java's LongAdder, a Map based on a modified cache-line hash table, an unbounded UMPSCQueue, a bounded SPSCQueue, an MPMC queue and an RWMutex variant. The Map follows the sync.Map interface with extensions such as Compute and Size.

How is the xsync Map different from sync.Map?

It uses a modified Cache-Line Hash Table, organising the hash table in cache-line-sized buckets so update operations complete with minimal cache-line transfer, and its Get operations are obstruction-free with no writes to shared memory and no locks. It also uses cooperative parallel rehashing, where goroutines doing writes participate in the resize instead of waiting for it.

What is the risk of the unbounded UMPSCQueue?

The README says it has infinite capacity and never exhibits backpressure, so if nothing is consuming elements the queue will eventually consume all available memory and crash the process. It is only appropriate where bursts are temporary and the consumer catches up, and multiple goroutines must not consume from it because no locks are acquired.

What are the consistency caveats of xsync Map iteration?

RangeRelaxed and AllRelaxed for Go 1.23+ iterators are lock-free but have relaxed consistency, so the same key may be visited more than once if it is concurrently deleted and re-inserted during the iteration. The normal Range and All methods are the safe options, and ToPlainMap converts the map to a built-in Go map.

What are the requirements for using xsync?

The latest major version is v4, so the import path needs the /v4 suffix, github.com/puzpuzpuz/xsync/v4, and the minimum required Go version is 1.24. The project is Apache-2.0 licensed, and benchmark results are published in BENCHMARKS.md in the repository.

Official sources

  1. Issues
  2. License: Apache-2.0
  3. puzpuzpuz/xsync on GitHub
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/puzpuzpuz-xsync.svg)](https://hysenlabs.com/projects/puzpuzpuz-xsync)

Was this useful?