• [c++]你最喜爱的stringstream和snprintf性能深入剖析


    最近写一个程序中两个差不多的模块,一个使用了snprintf输出中间数据,另一个偷懒使用stringstream。结果你猜怎么着?居然压帧了!!到底是谁拖了性能的后退?

    来自阿里云的性能分析实验

    我上网一搜,有人做了性能分析实验。他的实验demo大概有4个步骤:

    1. 循环体内部构造stringstream对象,填充数据
    2. 循环体外部构造stringstream对象,循环体内每次使用清空对象再使用
    3. 循环体内部创建buffer,使用snprintf填充数据
    4. 循环体外部创建buffer,循环体内先清空buffer再使用snprintf填充数据

    在十万次调用结束后,上述4种方式所耗时情况为: 方法 2 > 方法 3 > 方法 4 > 方法 1 方法2>方法3>方法4>方法1 方法2>方法3>方法4>方法1

    可见,不要干这种不必要的在循环体内部反复构造析构的事情。

    原因

    那么,为啥呢?这俩到底做了啥呢?

    C99 snprintf

    观察它的源码,会发现和其他printf一样,他是一个可变参函数,这就意味着它会经历一系列的递归展开:

    /* Maximum chars of output to write in MAXLEN.  */
    extern int snprintf (char *__restrict __s, size_t __maxlen,
    		     const char *__restrict __format, ...)
         __THROWNL __attribute__ ((__format__ (__printf__, 3, 4)));
    
    • 1
    • 2
    • 3
    • 4

    当展开到最底层的时候,这个函数首先根据所需的字符串长度预先分配内存,底层差不多这样:

    char* buf = (char*)malloc(buf_size);
    
    • 1

    然后,对分配的内存执行格式化操作:

    int result = vsnprintf(buf, buf_size, format, args);
    
    • 1

    可以看到有意思的是,他的参数展开是依赖于vsnprintf这个函数的:

    extern int vsnprintf (char *__restrict __s, size_t __maxlen,
    		      const char *__restrict __format, _G_va_list __arg)
         __THROWNL __attribute__ ((__format__ (__printf__, 3, 0)));
    
    • 1
    • 2
    • 3

    为了不让自己的头变得很大,我在这里做一个非常短小精悍的vsnprintf精华版实现:

    int vsnprintf(char *__restrict __s, size_t __maxlen,
    		      const char *__restrict __format, _G_va_list __arg) {  
        int result;  
        va_list copy;  
        va_copy(copy, args);  
        result = vsnprintf_l(__restrict __s, __maxlen, __restrict __format, copy);  
        va_end(copy);  
        return result;  
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    这里其实他的实现因编译器而异,我在这使用了vsnprintf_l,是一个线程安全的版本。

    接下来,打住!请确保你已经了解了可变参数列表和相关函数的基础知识!如果不太了解,过两天我再写一个博客(肥水不流外人田.jpg)

    接下来,我们看一下这个函数干了什么:

    1. va_copy(copy, args);创建了一个可变参数列表的副本。为什么要创建副本呢?本质上是为了防止修改参数列表而对原来的参数列表造成难以debug的痛苦影响。
    2. vsnprintf_l(__restrict __s, __maxlen, __restrict __format, copy);这个函数接收了下列参数并格式化了buf
      1. 指向我们指定的、要写入的buf的指针
      2. 我们指定的buf的大小
      3. 包含结果字符串格式的格式化字符串(回文表达,耶!)
      4. 参数列表,要写入字符串中的实际值
    3. 我们的vsnprintf_l函数返回了一个整数值,表示成功写入到缓冲区的字符数(不包括结尾的空字符)。这个值会被vsnprintf函数返回给调用者。
    4. 为了不发生内存泄漏,在最后一步清理参数列表。

    显然,这个时候,我们陷入了一个套娃:看起来snprintf要做的事,被vsnprintf拿去了,而vsnprintf要做的事,又被vsnprintf_l拿去了!

    为什么呢?因为涉及到数据的写入,我们一定得考虑多线程的情况下是否写入操作是安全的。

    我们来看看这个线程安全的vsnprintf_l:

    #define MAX_BUFFER_SIZE 1024
    
    typedef struct {
    	locate_t locate;
    	char buffer[MAX_BUFFER_SIZE];
    	size_t size;
    }vsnprintf_data;
    
    static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
    
    // 名字很长长长长的参数名写不动了,换成小名吧 =.=
    int vsnprintf_l(char *str, size_t size, const char *format, va_list args) {  
        vsnprintf_data data;  
        data.size = size;  
        data.locale = locale_t();  
        if (format) {  
            data.locale = newlocale(LC_ALL_MASK, format, data.locale);  
        }  
        int result = vsnprintf(data.buffer, MAX_BUFFER_SIZE, format, args);  
        if (data.locale) {  
            freelocale(data.locale);  
        }  
        return result;  
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    我们看到,我们有一个用于存储线程安全的数据的结构体vsnprintf_data,它的内容是这样的:

    1. locate_t locate:存储当前线程的locate信息
    2. buffer:存储格式化后的字符串
    3. size:我们的老朋友size,表示缓冲区的大小

    我们首先定义了一个互斥锁来守护多线程环境下操作的正确性。接着:

    1. 创建了一个vsnprintf_data的实例data,将它的size初始化为传入的大小参数。如果传递了格式化字符串(format),那么我们使用newlocate函数创造一个新的locate对象,并把它存在data.locate中。这个locate对象是根据传递的格式化字符串创建的,用于支持特定的语言环境。
    2. 接着,函数调用vsnprintf函数,将数据写入data.buffer中。我们看到套娃开始:vsnprintf函数会根据指定的格式化字符串和参数列表将数据格式化为字符串,并将结果写入到缓冲区中。如果格式化后的字符串超过了缓冲区的大小,vsnprintf会自动调整缓冲区大小,动态地分配和释放内存。格式化后的字符串超过了最初分配的内存大小,函数会通过调用realloc来重新分配一块足够大的内存区域,并再次进行格式化操作。如果在第一次分配内存后有足够的空间容纳格式化后的字符串,那么不会发生重新分配内存的情况。完成格式化操作后,可以通过调用free来释放分配的内存。
    3. 如果创建了新的locale对象,函数会使用freelocale函数释放该对象。然后返回vsnprintf函数的返回值,表示成功写入到缓冲区的字符数(不包括结尾的空字符)。

    需要注意的是,在snprintf函数中,每次重新分配内存后,新的内存块会被写入到原始内存块的后面,以充分利用已分配的内存空间。此外,如果在第一次分配内存后有足够的空间容纳格式化后的字符串,那么不会发生重新分配内存的情况。

    可以看到,由于格式化字符串解析的复杂性、参数的数量和类型、字符串的大小和内容等因素,这个函数的性能会受到一些影响。

    stringstream

    完了,打不到车了。我先打车回家明天再写55555

    我来更新了。

    我们再看std::stringstream

    stringstream本质上就是一个类,我截取了一部分定义:

    
      template <typename _CharT, typename _Traits, typename _Alloc>
        class basic_stringstream : public basic_iostream<_CharT, _Traits>
        {
        public:
          // Types:
          typedef _CharT 					char_type;
          typedef _Traits 					traits_type;
          // _GLIBCXX_RESOLVE_LIB_DEFECTS
          // 251. basic_stringbuf missing allocator_type
          typedef _Alloc				       	allocator_type;
          typedef typename traits_type::int_type 		int_type;
          typedef typename traits_type::pos_type 		pos_type;
          typedef typename traits_type::off_type 		off_type;
    
          // Non-standard Types:
          typedef basic_string<_CharT, _Traits, _Alloc> 	__string_type;
          typedef basic_stringbuf<_CharT, _Traits, _Alloc> 	__stringbuf_type;
          typedef basic_iostream<char_type, traits_type>	__iostream_type;
    
        private:
          __stringbuf_type	_M_stringbuf;
    
        public:
          // Constructors/destructors
          /**
           *  @brief  Default constructor starts with an empty string buffer.
           *  @param  __m  Whether the buffer can read, or write, or both.
           *
           *  Initializes @c sb using the mode from @c __m, and passes @c
           *  &sb to the base class initializer.  Does not allocate any
           *  buffer.
           *
           *  That's a lie.  We initialize the base class with NULL, because the
           *  string class does its own memory management.
          */
          explicit
          basic_stringstream(ios_base::openmode __m = ios_base::out | ios_base::in)
          : __iostream_type(), _M_stringbuf(__m)
          { this->init(&_M_stringbuf); }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40

    可以看到,它里面也有一个用于存放string的buffer,使用ios::inios::out来执行底层的输入输出。

    既然有buffer,那我们就应该想到,这个类会确保缓冲区是否已满,是否buffer需要清空重新分配。而这也应该是影响它性能最大的因素。

    想到stringstream写入数据的三种方式:

    1. put():它将单个字符写入到stringstream我们刚才所看到的缓冲区中。在使用这个函数时,会检查当前缓冲区是否满,如果不满,则直接写入;否则分配更大的空间。
    2. write():这个函数将制定数量的字符从给定的字符数组写入到缓冲区。
    3. 复杂类型重载的<<运算符。

    看着还是后者方便啊。叹息。

  • 相关阅读:
    mysql有关查询的操作
    CMS与FullGC
    小区疫情管理系统
    Java研发规范
    Redis小而巧的数据库真的很实用,掌握了用起来很舒服
    AWS SAP-C02教程3--网络资源
    定时任务&多线程-springboot
    axios的简介认识(从头到尾详细)
    构建离线应用:Apollo与本地状态管理
    鸿蒙(HarmonyOS)项目方舟框架(ArkUI)之NavDestination组件
  • 原文地址:https://blog.csdn.net/weixin_43395063/article/details/134254867