[php-src] PR #25420: Make setlocale() thread-local in ZTS builds

From: Date: Thu, 12 Nov 2026 13:59:18 +0000
Subject: [php-src] PR #25420: Make setlocale() thread-local in ZTS builds
Groups: php.git-pulls 
Request: Send a blank email to git-pulls+get-40580@lists.php.net to get a copy of this message
Pull Request: https://github.com/php/php-src/pull/25420 Author: felixbuenemann In thread-safe builds, setlocale() changes the locale of the whole process: - a request uses the locale set by requests running in other threads; - the reset at the end of a request also resets the locale of all other requests; - concurrent calls can crash, because the C library's setlocale() isn't thread-safe. The manual documents the first point as a limitation. Windows doesn't have it: PHP keeps the locale per thread there since 5.3.6 (0778bedbe1f) and 5.6.20/7.0.5 (632fc51d984, bug #71596). This PR does the same for the other platforms, using newlocale() and uselocale(). It takes the approach of #20531, which was closed in favour of a broader ZTS rework. On internals ([externals.io/message/128991](https://externals.io/message/128991)), a pull request was suggested rather than an RFC. ## Changes - New zend_setlocale() in Zend/zend_locale.c. In ZTS builds with uselocale(), during a request it switches the current thread to a locale of its own. The locale-dependent functions of the C library (printf(), localeconv(), strftime(), strcoll(), nl_langinfo(), mblen()…) use that locale in place of the process locale. In other builds it's a macro for setlocale(). - PHP's own setlocale() calls go through it: the setlocale() function, the reset in PHP_RSHUTDOWN(basic), zend_reset_lc_ctype_locale(), zend_update_current_locale() and NumberFormatter::parse(). - configure.ac checks for uselocale() and xlocale.h. Behaviour that doesn't change: - The process locale is still set during startup, and it is the initial locale of every thread. Outside of a request (!EG(active)), zend_setlocale() changes the process locale as before. - A request that changed the locale still resets it to C at the end. That now only affects its own thread, as on Windows. - setlocale() returns the same names as in NTS builds, including the name of LC_ALL when the categories have different locales. That name has a different format in glibc, musl and macOS/BSD, so setlocale(LC_ALL, $saved) keeps working. - NTS builds and Windows are unaffected. ## Implementation notes - A locale object is created once for each combination of category locales and shared by all threads. Threads only switch between these objects with uselocale(). - The locale objects are never freed. Threads keep using them, a process only needs a few, and macOS crashes when newlocale() and freelocale() run concurrently. The number of objects is not bounded, though: it grows with the distinct combinations a process sets. - Lookup and creation happen under tsrm_env_lock(), because the "" locale reads the environment. - Requests that don't call setlocale() run no new code. - A thread that hasn't set a locale never calls setlocale(LC_ALL, NULL): on macOS that call writes to a static buffer, so concurrent calls return garbage. ## Tests - setlocale_variation6.phpt (new) covers restoring a saved name of LC_ALL when the categories have different locales. - setlocale_variation4.phpt and setlocale_variation5.phpt had a trailing semicolon in their --ENV-- section, so the locale was en_US.utf8;. glibc accepts that name in setlocale(), but not in newlocale(). The semicolon is removed. - phpt tests can't run threads. I tested the thread behaviour with the program below, which uses the embed SAPI: 9 threads run 2,000 requests each. A third of the threads switch to German, a third to French, and a third never set a locale. Each request checks its initial locale and its results. | | macOS 27 | Debian 13 (glibc 2.41) | Alpine 3.24 (musl) | |---|---|---|---| | This PR | passes (3 of 3 runs) | passes (3 of 3 runs) | passes (3 of 3 runs) | | master | aborts (3 of 3 runs) | wrong locales (3 of 3 runs) | wrong locales (3 of 3 runs) | - On the same three systems, I compared the output of a ZTS build with an NTS build for 61 setlocale() edge cases (invalid names, '0', '', null, arrays, POSIX, wrong types…), 7 environments (LANG, LC_ALL, LC_NUMERIC, none…) and 15 names of LC_ALL with missing, extra or empty parts. The output is identical. - The tests in ext/standard/tests/strings, ext/standard/tests/general_functions, ext/ctype, ext/pcre and tests/lang give the same results in ZTS and NTS builds on all three systems. The NumberFormatter tests pass on Debian and Alpine. - Not tested: FreeBSD, Solaris and Windows. ## Limitations - C code that calls the C library's setlocale() directly, in an extension or a library, still changes the process locale, and doesn't see the locale of the thread. Extensions should call zend_setlocale() instead (see UPGRADING.INTERNALS). - On systems where LC_ALL is negative (AIX, as far as I know), the category table in zend_locale.c won't compile in ZTS builds. - On other C libraries than glibc, musl and those of macOS and the BSDs, the name of LC_ALL for different category locales uses the musl format. It still round-trips through setlocale(). <details> <summary>Thread test program (embed SAPI)</summary> ```c /* Runs PHP requests in several threads with the embed SAPI. A third of the * threads switch to German, a third to French and a third never set a locale. * Each request checks that it starts with the initial locale and that requests * in other threads don't change its locale. * Usage: threads [threads] [requests per thread] */ #include <sapi/embed/php_embed.h> #include <locale.h> #include <pthread.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #define KINDS 3 static int requests = 2000; static const char *locales[KINDS] = {"de_DE.UTF-8", "fr_FR.UTF-8", ""}; static char codes[KINDS][2048]; static char expected[KINDS][1024]; /* Returns "<initial locale>|<locale>|<decimal point>|<number>|<month>" */ static const char *script = "(function () {" " $locale = '%s';" " $initial = setlocale(LC_ALL, 0);" " $set = $locale === '' ? $initial : setlocale(LC_ALL, $locale);" " usleep(50);" " $result = implode('|', [$initial, $set, setlocale(LC_ALL, 0)," " localeconv()['decimal_point'], sprintf('%%.1f', 1.5), nl_langinfo(MON_1)]);" " if (%d) { setlocale(LC_ALL, $initial); }" " return $result;" "})()"; static bool eval(const char *code, char *result, size_t size) { zval retval; if (zend_eval_string(code, &retval, "request") != SUCCESS || Z_TYPE(retval) != IS_STRING) { return false; } snprintf(result, size, "%s", Z_STRVAL(retval)); zval_ptr_dtor(&retval); return true; } static void *run(void *arg) { long id = (long) arg, wrong = 0; int kind = id % KINDS; char result[1024], example[1024] = ""; (void) ts_resource(0); for (int i = 0; i < requests; i++) { php_request_startup(); if (!eval(codes[kind], result, sizeof(result)) || strcmp(result, expected[kind]) != 0) { if (!wrong++) { snprintf(example, sizeof(example), "%s", result); } } php_request_shutdown(NULL); } printf("thread %2ld (%-11s): %ld of %d requests wrong%s%s\n", id, locales[kind][0] ? locales[kind] : "no locale", wrong, requests, wrong ? ", e.g. " : "", example); ts_free_thread(); return (void *) wrong; } int main(int argc, char **argv) { int count = argc > 1 ? atoi(argv[1]) : 9; long failed = 0; char initial[512], code[2048]; pthread_t threads[64]; if (argc > 2) { requests = atoi(argv[2]); } php_embed_init(1, argv); /* The expected results, from a single thread */ for (int kind = 0; kind < KINDS; kind++) { snprintf(code, sizeof(code), script, locales[kind], 1); snprintf(codes[kind], sizeof(codes[kind]), script, locales[kind], 0); if (!eval(code, expected[kind], sizeof(expected[kind]))) { return 2; } printf("expected (%-11s): %s\n", locales[kind][0] ? locales[kind] : "no locale", expected[kind]); } snprintf(initial, sizeof(initial), "%s", setlocale(LC_ALL, NULL)); for (long i = 0; i < count; i++) { pthread_create(&threads[i], NULL, run, (void *) i); } for (int i = 0; i < count; i++) { void *result; pthread_join(threads[i], &result); failed += (long) result; } if (strcmp(initial, setlocale(LC_ALL, NULL)) != 0) { printf("process locale changed from %s to %s\n", initial, setlocale(LC_ALL, NULL)); failed++; } printf(failed ? "FAILED\n" : "OK\n"); php_embed_shutdown(); return failed != 0; } ``` Built with --enable-zts --enable-embed, and linked against libphp. </details>

« previous php.git-pulls (#40580) next »