From d3681cc59d49312c32dd9fc921dd4a9ecdac8790 Mon Sep 17 00:00:00 2001 From: Artem Ukrainskiy Date: Thu, 8 Oct 2026 21:03:12 +0300 Subject: [PATCH 1/2] ext/standard: Skip mbrlen() for ASCII bytes in php_mblen() fgetcsv(), str_getcsv(), escapeshellarg() and escapeshellcmd() call php_mblen() on every byte, and with glibc mbrlen() is most of their time. When the locale uses ASCII characters as singletons (CG(ascii_compatible_locale)), a byte below 0x80 is one character and is counted without the call. php_mb_reset() records that at the start of a string; escapeshellarg() and escapeshellcmd() now call it like the other callers do. Other locales keep going through mbrlen() with the state held across the string, and a decoder that flushes a buffered character without consuming input is called again for the same byte. About 8x on fgetcsv() and 6x on escapeshellarg() with ASCII input. --- ext/standard/basic_functions.h | 5 ++--- ext/standard/exec.c | 4 ++++ ext/standard/php_string.h | 34 ++++++++++++++++++++++++---------- ext/standard/string.c | 1 + 4 files changed, 31 insertions(+), 13 deletions(-) diff --git a/ext/standard/basic_functions.h b/ext/standard/basic_functions.h index 4d5a4c02aec0..300cedfe7959 100644 --- a/ext/standard/basic_functions.h +++ b/ext/standard/basic_functions.h @@ -98,10 +98,9 @@ typedef struct _php_basic_globals { HashTable url_adapt_output_hosts_ht; HashTable *user_filter_map; - /* file.c */ -#if defined(_REENTRANT) + /* php_mblen() */ mbstate_t mblen_state; -#endif + bool mblen_ascii_singletons; int umask; zend_long unserialize_max_depth; diff --git a/ext/standard/exec.c b/ext/standard/exec.c index 5c9e5c6076a8..b1ca34147d8d 100644 --- a/ext/standard/exec.c +++ b/ext/standard/exec.c @@ -287,11 +287,13 @@ PHPAPI zend_string *php_escape_shell_cmd(const zend_string *unescaped_cmd) cmd = zend_string_safe_alloc(2, l, 0, 0); + php_mb_reset(); for (x = 0, y = 0; x < l; x++) { int mb_len = php_mblen(str + x, (l - x)); /* skip non-valid multibyte characters */ if (mb_len < 0) { + php_mb_reset(); continue; } else if (mb_len > 1) { memcpy(ZSTR_VAL(cmd) + y, str + x, mb_len); @@ -400,11 +402,13 @@ PHPAPI zend_string *php_escape_shell_arg(const zend_string *unescaped_arg) ZSTR_VAL(cmd)[y++] = '\''; #endif + php_mb_reset(); for (x = 0; x < l; x++) { int mb_len = php_mblen(str + x, (l - x)); /* skip non-valid multibyte characters */ if (mb_len < 0) { + php_mb_reset(); continue; } else if (mb_len > 1) { memcpy(ZSTR_VAL(cmd) + y, str + x, mb_len); diff --git a/ext/standard/php_string.h b/ext/standard/php_string.h index 7b8f8a8d3da7..837ce7bfb9b0 100644 --- a/ext/standard/php_string.h +++ b/ext/standard/php_string.h @@ -57,16 +57,30 @@ PHPAPI size_t php_strcspn(const char *s1, const char *s2, const char *s1_end, co PHPAPI bool php_binary_string_shuffle(php_random_algo_with_state engine, char *str, zend_long len); -#ifdef _REENTRANT -# ifdef PHP_WIN32 -# include -# endif -# define php_mblen(ptr, len) ((int) mbrlen(ptr, len, &BG(mblen_state))) -# define php_mb_reset() memset(&BG(mblen_state), 0, sizeof(BG(mblen_state))) -#else -# define php_mblen(ptr, len) mblen(ptr, len) -# define php_mb_reset() php_ignore_value(mblen(NULL, 0)) -#endif +#include +/* Starts a new string. A byte below 0x80 is one character when the locale uses ASCII + * characters as singletons */ +static zend_always_inline void php_mb_reset_ex(mbstate_t *state, bool *ascii_singletons) +{ + memset(state, 0, sizeof(*state)); + *ascii_singletons = CG(ascii_compatible_locale); +} + +static zend_always_inline int php_mblen_ex(const char *ptr, size_t len, mbstate_t *state, bool ascii_singletons) +{ + if (len > 0 && *ptr != '\0' && (unsigned char) *ptr < 0x80 && ascii_singletons) { + return 1; + } + int result = (int) mbrlen(ptr, len, state); + while (result == 0 && *ptr != '\0') { + /* A buffered character was flushed without consuming the byte */ + result = (int) mbrlen(ptr, len, state); + } + return result; +} + +#define php_mblen(ptr, len) php_mblen_ex(ptr, len, &BG(mblen_state), BG(mblen_ascii_singletons)) +#define php_mb_reset() php_mb_reset_ex(&BG(mblen_state), &BG(mblen_ascii_singletons)) #define PHP_STR_PAD_LEFT 0 #define PHP_STR_PAD_RIGHT 1 diff --git a/ext/standard/string.c b/ext/standard/string.c index 2e949da741dc..8163ed4be73c 100644 --- a/ext/standard/string.c +++ b/ext/standard/string.c @@ -1482,6 +1482,7 @@ PHPAPI zend_string *php_basename(const char *s, size_t len, const char *suffix, * State 1 is everything else. */ int state = 0; + php_mb_reset(); basename_start = s; basename_end = s; while (len > 0) { From 05e9146c5cd36e24e8328470089dd5d7b9d4497e Mon Sep 17 00:00:00 2001 From: Artem Ukrainskiy Date: Fri, 9 Oct 2026 15:16:30 +0300 Subject: [PATCH 2/2] Decode a flushed character from the initial state, add UPGRADING notes Retrying mbrlen() with the same state loops on a shift sequence followed by NUL in a stateful encoding; decode the byte from the initial state instead, which ends after one retry. --- UPGRADING | 3 +++ UPGRADING.INTERNALS | 3 +++ ext/standard/php_string.h | 5 +++-- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/UPGRADING b/UPGRADING index d8d763986d0f..342cff7d6fc9 100644 --- a/UPGRADING +++ b/UPGRADING @@ -96,6 +96,9 @@ PHP 8.7 UPGRADE NOTES ======================================== - Standard: + . Improved performance of fgetcsv(), str_getcsv(), escapeshellarg() and + escapeshellcmd() in locales where ASCII characters are single bytes + (C, UTF-8 and single-byte locales). . Improved performance of strcspn() and strpbrk() when searching longer strings with a single-byte character mask. . Improved performance of array_chunk() when not preserving keys. diff --git a/UPGRADING.INTERNALS b/UPGRADING.INTERNALS index 2f6ea7d9ea84..8870a02b6fb4 100644 --- a/UPGRADING.INTERNALS +++ b/UPGRADING.INTERNALS @@ -75,6 +75,9 @@ PHP 8.7 INTERNALS UPGRADE NOTES - Standard: . php_explode() now takes a HashTable* instead of a zval* to store the resulting parts. It already expected the zval* to be IS_ARRAY. + . php_mblen() and php_mb_reset() are inline functions. php_mb_reset() must be + called before the first php_mblen() of a string; the conversion state is + kept in BG(mblen_state) in NTS builds too. ======================== 4. OpCode changes diff --git a/ext/standard/php_string.h b/ext/standard/php_string.h index 837ce7bfb9b0..e02dd4e7bb1f 100644 --- a/ext/standard/php_string.h +++ b/ext/standard/php_string.h @@ -72,8 +72,9 @@ static zend_always_inline int php_mblen_ex(const char *ptr, size_t len, mbstate_ return 1; } int result = (int) mbrlen(ptr, len, state); - while (result == 0 && *ptr != '\0') { - /* A buffered character was flushed without consuming the byte */ + if (result == 0 && *ptr != '\0') { + /* A buffered character was flushed without consuming the byte; decode it from the initial state */ + memset(state, 0, sizeof(*state)); result = (int) mbrlen(ptr, len, state); } return result;