diff --git a/lib/gis/CMakeLists.txt b/lib/gis/CMakeLists.txt index 711725b02b3..2739ca78511 100644 --- a/lib/gis/CMakeLists.txt +++ b/lib/gis/CMakeLists.txt @@ -4,7 +4,13 @@ if(NOT WIN32) list(FILTER gislib_SRCS EXCLUDE REGEX [[.*/fmode\.c$]]) endif() +find_package(Threads) + set(grass_gis_DEFS "-DGRASS_VERSION_DATE=\"${GRASS_VERSION_DATE}\"") +if(Threads_FOUND) + list(APPEND grass_gis_DEFS "HAVE_PTHREAD") +endif() + if(MSVC) set(grass_gis_DEFS "${grass_gis_DEFS};-D_USE_MATH_DEFINES=1") set(gislib_INCLUDES "../../msvc") diff --git a/lib/gis/Makefile b/lib/gis/Makefile index 90b39f374a4..f2d2ba0292a 100644 --- a/lib/gis/Makefile +++ b/lib/gis/Makefile @@ -2,7 +2,7 @@ MODULE_TOPDIR = ../.. LIB = GIS -LIBES = $(OPENMP_LIBPATH) $(OPENMP_LIB) +LIBES = $(OPENMP_LIBPATH) $(OPENMP_LIB) $(PTHREADLIB) EXTRA_INC = $(ZLIBINCPATH) $(BZIP2INCPATH) $(ZSTDINCPATH) $(PTHREADINCPATH) $(REGEXINCPATH) $(OPENMP_INCPATH) EXTRA_CFLAGS = $(OPENMP_CFLAGS) -DGRASS_VERSION_DATE=\"'$(GRASS_VERSION_DATE)'\" diff --git a/lib/gis/lrand48.c b/lib/gis/lrand48.c index 63d77d9fb99..b7ac8ac136f 100644 --- a/lib/gis/lrand48.c +++ b/lib/gis/lrand48.c @@ -3,12 +3,12 @@ * * \brief GIS Library - Pseudo-random number generation * - * (C) 2014 by the GRASS Development Team + * (C) 2014-2025 by the GRASS Development Team * * This program is free software under the GNU General Public License * (>=v2). Read the file COPYING that comes with GRASS for details. * - * \author Glynn Clements + * \authors Glynn Clements, Maris Nartiss (thread safety) */ #include @@ -19,6 +19,10 @@ #include #include +#ifdef HAVE_PTHREAD +#include +#endif + #ifdef HAVE_GETTIMEOFDAY #include #else @@ -41,6 +45,10 @@ static const uint32 b0 = 0xB; static int seeded; +#ifdef HAVE_PTHREAD +static pthread_mutex_t lrand48_mutex = PTHREAD_MUTEX_INITIALIZER; +#endif + #define LO(x) ((x) & 0xFFFFU) #define HI(x) ((x) >> 16) @@ -48,15 +56,27 @@ static int seeded; * \brief Seed the pseudo-random number generator * * \param[in] seedval 32-bit integer used to seed the PRNG + * + * In a multi-threaded program, call `G_srand48()` once from the main + * thread *before* starting the worker threads. Subsequent calls will + * reset global seed state used by all threads. */ void G_srand48(long seedval) { uint32 x = (uint32) * (unsigned long *)&seedval; +#ifdef HAVE_PTHREAD + pthread_mutex_lock(&lrand48_mutex); +#endif + x2 = (uint16)HI(x); x1 = (uint16)LO(x); x0 = (uint16)0x330E; seeded = 1; + +#ifdef HAVE_PTHREAD + pthread_mutex_unlock(&lrand48_mutex); +#endif } /*! @@ -65,6 +85,10 @@ void G_srand48(long seedval) * A weak hash of the current time and PID is generated and used to * seed the PRNG * + * In a multi-threaded program, call `G_srand48_auto()` once from the main + * thread *before* starting the worker threads. Subsequent calls will + * reset global seed state used by all threads. + * * \return generated seed value passed to G_srand48() */ long G_srand48_auto(void) @@ -128,40 +152,68 @@ static void G__next(void) /*! * \brief Generate an integer in the range [0, 2^31) * + * This function is thread-safe. + * * \return the generated value */ long G_lrand48(void) { uint32 r; +#ifdef HAVE_PTHREAD + pthread_mutex_lock(&lrand48_mutex); +#endif + G__next(); r = ((uint32)x2 << 15) | ((uint32)x1 >> 1); + +#ifdef HAVE_PTHREAD + pthread_mutex_unlock(&lrand48_mutex); +#endif + return (long)r; } /*! * \brief Generate an integer in the range [-2^31, 2^31) * + * This function is thread-safe. + * * \return the generated value */ long G_mrand48(void) { uint32 r; +#ifdef HAVE_PTHREAD + pthread_mutex_lock(&lrand48_mutex); +#endif + G__next(); r = ((uint32)x2 << 16) | ((uint32)x1); + +#ifdef HAVE_PTHREAD + pthread_mutex_unlock(&lrand48_mutex); +#endif + return (long)(int32)r; } /*! * \brief Generate a floating-point value in the range [0,1) * + * This function is thread-safe. + * * \return the generated value */ double G_drand48(void) { double r = 0.0; +#ifdef HAVE_PTHREAD + pthread_mutex_lock(&lrand48_mutex); +#endif + G__next(); r += x2; r *= 0x10000; @@ -169,6 +221,11 @@ double G_drand48(void) r *= 0x10000; r += x0; r /= 281474976710656.0; /* 2^48 */ + +#ifdef HAVE_PTHREAD + pthread_mutex_unlock(&lrand48_mutex); +#endif + return r; } diff --git a/lib/gis/testsuite/test_lrand48.py b/lib/gis/testsuite/test_lrand48.py new file mode 100644 index 00000000000..f3b1c9eb90c --- /dev/null +++ b/lib/gis/testsuite/test_lrand48.py @@ -0,0 +1,108 @@ +"""Test of gis library lrand48 PRNG thread-safety + +@author Maris Nartiss +@author Gemini + +@copyright 2025 by the GRASS Development Team + +@license This program is free software under the GNU General Public License (>=v2). +Read the file COPYING that comes with GRASS +for details +""" + +import ctypes +import threading + +from grass.gunittest.case import TestCase +from grass.gunittest.main import test + +from grass.lib.gis import G_srand48, G_lrand48 + + +class Lrand48ThreadSafetyTestCase(TestCase): + """Test case for lrand48 thread-safety and reproducibility.""" + + def test_thread_safety_and_reproducibility(self): + """Verify that multi-threaded execution produces the same set of + random numbers as single-threaded execution.""" + + seed = 1337 + num_values = 10000 + num_threads = 4 + values_per_thread = num_values // num_threads + + self.assertEqual( + num_values % num_threads, + 0, + "Total number of values must be divisible by the number of threads.", + ) + + # --- Define ctypes function signatures --- + G_srand48.argtypes = [ctypes.c_long] + G_srand48.restype = None + + G_lrand48.argtypes = [] + G_lrand48.restype = ctypes.c_long + + # --- 1. Single-threaded execution --- + list_single = [] + G_srand48(seed) + for _ in range(num_values): + list_single.append(G_lrand48()) + + # --- 2. Multi-threaded execution --- + list_multi_raw = [] + lock = threading.Lock() + + def worker(): + """Calls G_lrand48 and appends the result to a shared list.""" + local_results = [] + for _ in range(values_per_thread): + # G_lrand48() itself is protected by a C-level mutex + local_results.append(G_lrand48()) + + # Use a Python-level lock to safely extend the shared list + with lock: + list_multi_raw.extend(local_results) + + # Reset the seed to ensure the sequence starts from the beginning + G_srand48(seed) + + threads = [] + for _ in range(num_threads): + thread = threading.Thread(target=worker) + threads.append(thread) + thread.start() + + for thread in threads: + thread.join() + + # --- 3. Verification --- + self.assertEqual( + len(list_single), + len(list_multi_raw), + "Single-threaded and multi-threaded runs produced a different number of values.", + ) + + # Check for duplicates in the multi-threaded list. The presence of duplicates + # would indicate that the C-level mutex failed and multiple threads + # received the same random number. + self.assertEqual( + len(list_multi_raw), + len(set(list_multi_raw)), + "Duplicate values found in multi-threaded run, indicating a race condition.", + ) + + # The sorted lists of numbers must be identical. + # This confirms that although threads ran in parallel, the C-level mutex + # correctly serialized access to the PRNG, yielding the exact same + # block of numbers. + self.assertListEqual( + sorted(list_single), + sorted(list_multi_raw), + "The set of generated numbers differs between single-threaded and multi-threaded runs.", + ) + + +if __name__ == "__main__": + test()