Skip to content

Commit b4288a7

Browse files
committed
Reuse a per-thread context in Proj::new/new_known_crs
Proj::new and Proj::new_known_crs now borrow the per-thread shared context instead of creating (and destroying) a fresh PROJ context each call, mirroring pyproj's one-context-per-thread model. This drops Proj::new to ~1us per instance. ProjBuilder and the clone-based constructors continue to own their own contexts. Adds tests for per-thread reuse, per-thread isolation, ProjBuilder context independence, and that dropping a Proj does not free the shared context. Signed-off-by: Stephan Hügel <shugel@tcd.ie>
1 parent a67b941 commit b4288a7

4 files changed

Lines changed: 95 additions & 6 deletions

File tree

CHANGES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
the lifetime of the process, substantially speeding up repeated creation of
99
transformation objects (https://github.com/georust/proj/issues/256).
1010
- Add Proj::equivalent_to() method to assess whether two Proj instances are the same
11+
- `Proj::new` and `Proj::new_known_crs` now reuse one PROJ context per thread
12+
instead of creating a new one per call, reducing construction time from
13+
~300us to ~1us per object (https://github.com/georust/proj/issues/256).
1114
- Refactored options logic
1215

1316
# 0.31.0 - 2025-08-29

src/context.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,6 @@ impl Drop for Context {
5454
/// exit.
5555
pub(crate) enum ProjContext {
5656
Owned(Context),
57-
// Wired into Proj::new/new_known_crs in a follow-up commit.
58-
#[allow(dead_code)]
5957
Shared(Rc<Context>),
6058
}
6159

@@ -89,7 +87,6 @@ thread_local! {
8987
}
9088

9189
/// Return a reference-counted handle to the calling thread's shared PROJ context.
92-
#[allow(dead_code)] // wired into Proj::new/new_known_crs in a follow-up commit
9390
pub(crate) fn thread_local_context() -> ProjContext {
9491
ProjContext::Shared(SHARED_CONTEXT.with(Rc::clone))
9592
}

src/lib.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,18 @@
150150
//! resources actually loaded and so do not grow without bound: the cost is memory residency for
151151
//! the lifetime of the process, not additional disk usage.
152152
//!
153+
//! ## Threading
154+
//!
155+
//! Each PROJ context (`PJ_CONTEXT`) is tied to a single thread and must not be used concurrently
156+
//! from more than one thread. `Proj` is therefore deliberately neither `Send` nor `Sync`; to
157+
//! transform coordinates on several threads, create a `Proj` on each thread.
158+
//!
159+
//! [`Proj::new`] and [`Proj::new_known_crs`] reuse one context per thread rather than creating a
160+
//! new one for every object. This keeps the connection to the PROJ database and its caches warm,
161+
//! so constructing many transformation objects on a thread is considerably cheaper than it would
162+
//! otherwise be. [`ProjBuilder`] keeps its own context, since it is used to configure
163+
//! context-specific state such as network access and search paths.
164+
//!
153165
//! ## Conform your own types
154166
//!
155167
//! If you have your own geometric types, you can conform them to the `Coord` trait and use `proj`

src/proj.rs

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ use std::{
2323
str,
2424
};
2525

26-
use crate::context::{Context, ProjContext};
26+
use crate::context::{Context, ProjContext, thread_local_context};
2727
use crate::cstring_array::CStringArray;
2828

2929
#[cfg(feature = "network")]
@@ -673,7 +673,7 @@ impl Proj {
673673
// PJ_LP signals projection of geodetic coordinates, with output being PJ_XY
674674
// and vice versa, or using PJ_XY for conversion operations
675675
pub fn new(definition: &str) -> Result<Proj, ProjCreateError> {
676-
ProjContext::Owned(Context::new()).transform_string(definition)
676+
thread_local_context().transform_string(definition)
677677
}
678678

679679
/// Try to create a new transformation object that is a pipeline between two known coordinate reference systems.
@@ -728,7 +728,7 @@ impl Proj {
728728
to: &str,
729729
area: Option<Area>,
730730
) -> Result<Proj, ProjCreateError> {
731-
ProjContext::Owned(Context::new()).transform_epsg(from, to, area)
731+
thread_local_context().transform_epsg(from, to, area)
732732
}
733733

734734
/// Create a transformation object that is a pipeline _between_ two known coordinate reference systems.
@@ -1655,6 +1655,81 @@ mod test {
16551655
let proj = Proj::new(wgs84).unwrap();
16561656
let np = proj.coordinate_metadata_create(epoch).unwrap();
16571657
assert_eq!(np.coordinate_metadata_get_epoch(), 2021.3);
1658+
// The metadata object clones the context, so it owns a distinct one rather than
1659+
// borrowing the shared per-thread context.
1660+
assert_ne!(np.ctx() as usize, proj.ctx() as usize);
1661+
}
1662+
1663+
#[test]
1664+
fn test_new_reuses_thread_context() {
1665+
// Every Proj::new on a thread borrows the same per-thread context.
1666+
let a = Proj::new("EPSG:4326").unwrap();
1667+
let b = Proj::new_known_crs("EPSG:4326", "EPSG:3857", None).unwrap();
1668+
assert_eq!(a.ctx(), b.ctx());
1669+
}
1670+
1671+
#[test]
1672+
fn test_each_thread_gets_its_own_context() {
1673+
// The shared context is per-thread: each thread must get a distinct context. A barrier
1674+
// keeps every context alive simultaneously while addresses are captured, so a freed
1675+
// context's address cannot be reused by another thread and falsely collide.
1676+
use std::sync::{Arc, Barrier};
1677+
1678+
let threads = 4;
1679+
let barrier = Arc::new(Barrier::new(threads + 1));
1680+
let main = Proj::new("EPSG:4326").unwrap();
1681+
1682+
let handles: Vec<_> = (0..threads)
1683+
.map(|_| {
1684+
let barrier = Arc::clone(&barrier);
1685+
// Send the context address (usize), not the !Send Proj itself.
1686+
std::thread::spawn(move || {
1687+
let proj = Proj::new("EPSG:4326").unwrap();
1688+
let addr = proj.ctx() as usize;
1689+
barrier.wait();
1690+
drop(proj);
1691+
addr
1692+
})
1693+
})
1694+
.collect();
1695+
1696+
barrier.wait();
1697+
let mut seen = vec![main.ctx() as usize];
1698+
for handle in handles {
1699+
seen.push(handle.join().unwrap());
1700+
}
1701+
1702+
let total = seen.len();
1703+
seen.sort_unstable();
1704+
seen.dedup();
1705+
assert_eq!(
1706+
seen.len(),
1707+
total,
1708+
"each thread should get a distinct context"
1709+
);
1710+
}
1711+
1712+
#[test]
1713+
fn test_builder_context_is_not_shared() {
1714+
// ProjBuilder owns its context (it mutates context state), so it must not borrow the
1715+
// shared per-thread context.
1716+
let shared = Proj::new("EPSG:4326").unwrap().ctx() as usize;
1717+
let built = ProjBuilder::new().proj("EPSG:4326").unwrap();
1718+
assert_ne!(built.ctx() as usize, shared);
1719+
}
1720+
1721+
#[test]
1722+
fn test_drop_does_not_free_shared_context() {
1723+
// Dropping a Proj must not destroy the shared context other Proj instances still use.
1724+
// Under AddressSanitizer this would surface as a use-after-free if the invariant broke.
1725+
let first = Proj::new("EPSG:4326").unwrap().ctx() as usize;
1726+
for _ in 0..1000 {
1727+
let _ = Proj::new("EPSG:4326").unwrap();
1728+
}
1729+
let transformer = Proj::new_known_crs("EPSG:4326", "EPSG:3857", None).unwrap();
1730+
assert_eq!(transformer.ctx() as usize, first);
1731+
// The shared context is still valid and usable.
1732+
transformer.convert((2.0, 49.0)).unwrap();
16581733
}
16591734

16601735
#[test]
@@ -1669,6 +1744,8 @@ mod test {
16691744

16701745
assert_relative_eq!(result.x(), 1450880.2910605022, epsilon = 1.0e-8);
16711746
assert_relative_eq!(result.y(), 1141263.0111604782, epsilon = 1.0e-8);
1747+
// The transformer clones the source context, so it owns a distinct one.
1748+
assert_ne!(transformer.ctx() as usize, from.ctx() as usize);
16721749
}
16731750

16741751
#[test]

0 commit comments

Comments
 (0)