读书笔记:提高C++性能的编程技术
Efficient C++ Performance Programming Techniques
第1章 跟踪范例
1.1 关注点
本章引入的实际问题为:定义一个简单的Trace类,将当前函数名输出到日志文件中。Trace对象会带来一定的开销,因此在默认情况下不会开启Trace功能。问题是:怎么设计Trace类,使得在不开启Trace功能时引入的开销最小。
1.2 使用状态变量开关功能
用宏来开关Trace功能很简单,在不开启时开销完全没有:
#ifdef TRACE Trace trace("aaa"); #endif
缺点是每次开关都需要重新编译。
使用状态变量的话有一定的运行时开销,但能保证灵活性,是一种比较合理的选择:
class Trace { public: ... static bool isTraceEnabled; void Debug() { if (isTraceEnabled) { ... } } }
1.3 延迟创建
原本的Trace类中内置string成员,这样在不开启Trace时也要承担构造和析构的开销。可以将其改为string*,并在真正需要开启时再创建该成员。
如果Trace的开启时间远小于总时间,则此方法很有效,否则当动态创建的开销大于固定的1次构造和析构的开销时,原方法更好一些。
第2章 构造函数和析构函数
2.1 关注点
继承和合成会导致构造函数和析构函数的开销超过你的预期。如何在代码重用性与运行性能间权衡值得关注。
2.2 去冗余对象
去掉不必要的对象使用,不管是类中的成员变量,还是函数的参数,都会带来不必要的构造和析构开销。
2.3 去冗余基类
有些基类没有成员变量,也没有提供接口的作用,这种基类就属于无意义的基类,在继承层次中去掉这样的基类可以减少以下几项开销:
- 构造和析构的开销:每个子类的相应过程中都减少了一次函数调用。
- 虚表的开销:减小了每个子类对象的体积,同时允许编译器将它们放入寄存器中进行优化。
- 虚函数的开销:详细的见下章,主要是虚函数不能内联,以及需要额外的跳转的开销。
2.4 嵌入对象与嵌入指针的权衡
如果直接嵌入对象的话,对象A的构造会导致对象B和C的构造,会导致对象B1 B2 C1 C2的构造……会形成一个构造树,层次较多时这个的开销会很巨大。
嵌入指针的话属于延迟创建,在第一次使用时才构造,但会带来new的开销。若经常使用则直接嵌入对象会好一些。
2.5 延迟创建
这里写的是将对象的定义和创建尽量延后,直到所有条件都具备了再进行创建。
2.6 去掉多余的构造开销
主要是指不必要的复制:
string s; s = "a"; // 有一次多余的赋值开销,还可能有一次将"a"转换为string临时对象的构造和析构开销
以及不在初始化列表中进行的初始化:
class A { public: A(const string &s) { name = s; //有一次多余的赋值开销 } private: string name; };
第3章 虚函数
3.1 关注点
利用好虚函数的优点:动态绑定,以及节省代码。尽量避免虚函数带来的开销。
3.2 虚函数的开销
可分为三种:
- 必须在构造函数内初始化vptr:这个相当于是在不使用虚函数的类中内置一个type变量的开销,是值得的,不讨论。
- 需要使用指针间接跳转:相当于在switch中通过type来调用相应版本函数的开销,不讨论。
- 虚函数不能内联:这个是关注点。
3.3 方案1:不继承
不继承的话就是将各子类独立出来,缺点是在代码中会充斥大量的switch,非常没有灵活性,排除。
3.4 方案2:继承
继承的缺点如3.2.1所述,成员函数无法内联,尤其是非常短小使用频繁的函数,会增加大量开销。
3.5 方案3:模板
使用模板来实现隐式接口:
template <typename LockType> void func(LockType &lock) { lock.Lock(); ... lock.Unlock(); }
实现了一个需要有Lock和Unlock的隐式接口。因为模板是在编译时确定的,因此生成的函数可以内联,同时还省去了指针间接跳转的开销。
缺点是模板导致的编译错误非常难以调试,同时C++不支持这种隐式接口,开发时经常会弄错模板的接口要求。
第4章 返回值优化
4.1 关注点
任何时候只要路过了对象的创建和清除,就会获得性能上的收益。编译器会在可能时去掉一些临时对象的创建和清除,这种优化被称作返回值优化(RVO)。
4.2 编译器可对匿名对象进行RVO
函数结尾直接返回一个匿名对象往往可以进行RVO:
string Func() { string a = "a"; return a; } string FuncRVO() { return string("a"); }
FuncRVO相比于Func更容易进行RVO,编译器会去掉返回的对象,而将其值直接赋给接收返回值的对象中。
4.3 主动进行RVO
如果类A有“+”操作如下:
const A operator +(const A &x, const A &y) { A z(x); z += y; return z; }
那么在A中增加一个构造函数:
class A { public: A(const A &x, const A &y); // A = x + y; };
则将“+”改为以下形式可获得RVO收益:
1
2
3 |
const
A operator
+( const
A &x, const
A &y) { return
A(x, y); } |
优点是减少了一个临时对象的构造和析构成本,缺点是要为所有需要进行RVO的操作分别新增一个类似的构造函数,灵活性太差。如果对性能要求特别高,可以考虑这种优化方法。
第5章 临时对象
5.1 关注点
如何避免产生不必要的临时对象。
5.2 类型不匹配
在不同类型间的赋值容易无意中导致临时对象的创建。可以通过在单参数构造函数前加explicit来避免这种隐式的转换产生。
5.3 避免重复创建相同的临时对象
如下循环中:
Complex a; for (int i = 0; i < 10; ++i) { a += 1.0; }
其中每次循环都会创建一个值为1.0的Complex对象。可以在循环外创建一个值为1.0的Complex对象,来减少这种开销:
Complex one(1.0); for (int i = 0; i < 10; ++i) { a += one; }
第6章 单线程内存池
6.1 关注点
默认的通用内存管理器的性能在特定场景下会造成一定的性能瓶颈。本章讨论的是在单线程环境下,每次分配固定大小和不固定大小的内存时,实现比通用new/delete性能更好的内存池管理器。
6.2 测试代码
Rational *array[1000]; for (int j = 0; j < 500; ++j) { for (int i = 0; i < 1000; ++i) { array[i] = new Rational(i); } for (int i = 0; i < 1000; ++i) { delete array[i]; } }
6.3 Rational专用内存池
每次分配Rational大小的内存块,用一个空闲链表维护已分配的空闲内存,在释放时重新将此内存块放回到链表中:
class Rational { ... static list<char *> freeList; void *operator new(size_t size) { if (freeList.empty()) { return new char[sizeof(Rational)]; } else { void *buf = freeList.back(); freeList.pop_back(); return buf; } } void operator delete(void *ptr, size_t size) { freeList.push_back(ptr); } };
此版本的内存池从不收缩,如果需要释放内存,则需要新增一个接口。
此版本的内存池与通用内存管理器相比,收益在于:
- 不用处理并发情况,没有临界区。
- 每次分配的大小为固定值,不用在空闲列表中进行大量的查找(直接返回末端指针)。
6.4 固定大小内存池
不只针对Rational,而是扩展为支持任意固定大小的类:
template <typename T> class FixedSizeMemoryPool { public: FixedSizeMemoryPool(): size_(sizeof(T)) {} ~FixedSizeMemoryPool() { for(char *&p: freeList_) { delete[] p; } } void *Alloc() { if (freeList_.empty()) { return new char[size_]; } else { void *buf = freeList_.back(); freeList_.pop_back(); return buf; } } void Free(void *buf) { freeList_.push_back(buf); } private: list<char *> freeList_; const size_t size_; };
Rational则需要改为:
class Rational { public: void *operator new(size_t size) { return pool.Alloc(); } void operator delete(void *ptr, size_t size) { pool.Free(ptr); } private: static FixedSizeMemoryPool<Rational> pool; };
6.5 不定大小内存池
不定大小的内存池的管理方法与上面的版本不同,因为没有办法直接从链表中返回一个内存块(大小不同)。这里我们在需要时分配一个大的固定大小的内存块,每次分配单个对象的内存时就从这个内存块上分配,空间不够时就分配大的内存块。
随着通用性的增加,性能也在逐渐下降。因此,在非常需要性能时,牺牲一些灵活性通用性也许会有很好的效果。
第7章 多线程内存池
7.1 关注点
在单线程的内存池中加入互斥锁,来实现多线程环境下可工作的分配器。在初始化分配器时可以传入锁的参数。
7.2 pthread_mutex版的内存池
在上一章MutableSizeMemoryPool中增加一个新的模板参数:typename LockType,允许传入一个LockType*,并在Alloc和Free时加锁,其它保持不变。
这一版的分配器的性能并不好,原因是pthread_mutex的性能超出了我们的需要,我们可能只需要一个功能很简单的锁。如果能传入一个更原始版本的互斥锁的话,会有更好的性能。
7.3 增加内存池的可伸缩性
目前版本的内存池在高并发环境下性能不好,因为对内存块的访问(Alloc和Free)必须要串行化。可以增加多个内存块的列表,并为每个列表单独加锁,这样可以把多个请求分散到不同的列表中同时进行处理。
第8章 内联基础
8.1 关注点
内联可能会提高性能,但也可能会降低性能。如何避免负面影响,同时利用好正面收益,是本章的关注点。
8.2 收益:去除函数调用
去除了函数调用的开销。一般的函数调用包括:
- 保存某些寄存器的值到栈上。
- 计算参数值并赋给调用函数的对应栈位置上。
- 跳转到指定函数。
- 保存栈帧指针。
- 执行代码。
- 复制返回值。
- 跳转回原位置。
- 恢复寄存器。
另外,还避免了进行跳转带来的处理器空转损失。
8.3 收益:跨函数优化
使得编译器可以进行跨函数的变量优化:
int Inc(int x) { return x + 1; } void Func() { int y = Inc(1); }
上面的Inc如果内联的话,Func就相当于:
void Func() { int x = 1; int y = x + 1; }
编译器甚至可以进一步优化为:
void Func() { int y = 2; }
通过内联,编译器可以重排大量的方法,从中省略掉大量不必要的语句,甚至包括对象的创建和清除。
8.4 收益:缩短关键路径
通过内联关键路径上的函数,可以在完全不改变程序逻辑的情况下缩短关键路径,从而大幅提高性能。
8.5 损失:编译后体积
内联后,函数代码会展开在每个调用点,如果函数代码量和调用点都比较多,则会导致代码体积膨胀。一方面会导致代码载入速度变慢,另一方面会导致更频繁的缺页发生。
但如果函数长度特别短,比整个调用过程还短,那么内联后反倒会减小代码体积,这种函数是一定要内联的。
8.6 损失:修改代码后必须重编译
内联的函数如果修改了代码,所有用到它的地方必须重新进行编译,因为该函数的代码已经在各个调用点展开了。因此大型工程往往在收尾时才进行内联。
第9章 内联——性能方面的考虑
9.1 关注点
本章主要关注内联的第2项收益:在内联函数的代码展开后,编译器针对其进行的各项性能优化。
9.2 调用间优化
内联后,调用函数的代码与调用处代码混合,这允许编译器进行很多高级的优化,如同将代码重新组织了一样。尤其是针对直接量进行的优化:
int Choice(int x) { switch (x) { case 1: return 5; break; case 2: ... ... case 100: return 301; break; default: return 0; break; } } int x = Choice(100);
在内联优化后,上面的代码可能只剩下:
int x = 301;
9.3 为何不使用内联
内联的主要缺点就是可能会增大代码体积。尤其是当相对庞大的方法被多层内联时会出现体积指数级膨胀的问题。
缺点2是每次修改需要全部重新编译。
缺点3是很难对内联函数进行调试,因为实际的函数已经没有了,无法追踪到函数的入口和出口。
9.4 基于配置的内联
在内联前应该统计各个函数的编译后体积和调用次数、调用点等信息,通过这些配置信息来决定对哪些函数进行内联。
可以将函数的尺寸分为:
- 静态尺寸:编译后的函数指令数*调用点数。
- 动态尺寸:运行过程中函数总的指令数(包括每次的调用指令和函数本身的指令)*调用次数。
对静态尺寸较小而动态尺寸较大的函数,内联会有很大的收益。对于只有一个调用点的函数,如循环内的调用,内联几乎总是对的。而对于调用点和调用次数都很多的函数,最好重写以展示出其快速路径,再进行内联。
某函数如下:
void FuncX { if (/* error handle code */) { ... // 30 lines } ... // real work (5 lines) }
FuncX有大约40行代码,表面上看不适于内联,但如果将它的错误处理代码拆成一个单独的函数:
void FuncX { if (...) FuncY(); ... // real work (5 lines) } void FuncY { ... // error handle code (30 lines) }
此时FuncX只有7行代码,很适合内联了。这也相当于将静态尺寸大而动态尺寸小的代码段拆出去,从而让剩余的静态尺寸小动态尺寸大的代码可以进行内联。
9.5 非常适合内联的函数
- 唯一函数:只有一个调用点的函数。
- 微小函数:语句少于5行的函数。
第10章 内联技巧
10.1 关注点
一些可帮助你更好的内联的技巧。
10.2 条件内联
如果想用一个预编译选项来控制某些函数何时内联,何时关闭内联,可以使用条件内联的技巧。
将内联函数的定义放到.inl中,其它函数的定义放到.cpp中,然后在.h中加入:
#ifdef INLINE #include "*.inl" #endif
在.inl中加入:
#ifndef INLINE #define inline // let inline be void #endif inline FuncX(...){}
在.cpp中加入:
#ifndef INLINE #include "*.inl" #endif
10.3 选择性内联
可以将某函数在一些调用点处内联,而在其它调用点处不内联。具体内容不是很喜欢,略过。
10.4 递归内联
尾递归的函数可以改成迭代函数,再寻找内联方法。
非尾递归的函数如果非常在意性能,可以将函数进行一定的展开:
void RecursiveInline() { ... Recursive(); ... } void Recursive() { ... RecursiveInline(); ... }
将前一个函数内联,这样会加快运行速度,但也会明显增加编译后体积。
也可以手动展开,或是用宏来维护,但很容易出问题。
10.5 特殊体系结构
有些体系结构下(如SPARC),函数调用的开销会在调用层次较少时非常的低,此时再内联那些非微小的函数的收益就很不明显了。因此,任何对非微小函数的内联都要建立在了解配置信息的基础上。
第11章 标准模板库
11.1 关注点
- STL与不同容器和算法在渐近复杂度方面的性能保证捆绑在一起,是怎么回事?
- STL由许多容器构成,面对一个给定的任务,应该使用哪个?
- STL的性能如何?如果自己开发,是否可以做得更好?
11.2 比STL更好
要比STL更好的话,往往要牺牲一定的通用性和灵活性,从一些特定的环境因素着手进行优化。
第12章 引用计数
12.1 关注点
C++使用了引用计数来解决垃圾回收问题,基本思想是把对象清除的责任从客户端代码转移给对象本身。
引用计数可以减少内存使用、避免内存泄漏,但在执行速度方面却可能会有坏处,尤其是在多线程环境中。
12.2 引用计数的实现
实现A:类内置引用计数。类RefCountBase封装和引用计数相关的操作,需要实现引用计数的类继承它:
class RefCountBase { public: Attach() { ++refCount_; } Detach() { if (--refCount_ == 0) { delete this; } } protected: RefCountBase(): refCount_(0) {} RefCountBase(const RefCountBase &rc): refCount_(0) {} RefCountBase &operator=(const RefCountBase &rc) { return *this; } virtual ~RefCountBase() {} size_t refCount_; };
如类A继承自RefCountBase,为了实现引用计数,还需要一个代理类SmartPtr充当A的智能指针:
template <typename T> class SmartPtr { public: SmartPtr(T *ptr = nullptr): ptr_(ptr) {} SmartPtr(const SmartPtr &sptr): ptr_(sptr.ptr_) { if (ptr_) { ptr_->Attach(); } } SmartPtr &operator=(const SmartPtr &sptr) { if (sptr.ptr_) { sptr.ptr_->Attach(); } if (ptr_) ptr_->Detach(); ptr_ = sptr.ptr_; return *this; } T *operator->() { return ptr_; } T &operator*() ( return *ptr_; ) private: T *ptr_; };
实现B:将计数功能放入SmartPtr中。去掉RefCountBase,而是在SmartPtr中增加一个size_t *count_,对ptr_的Attach操作变为++*count_,而Detach操作则变为--*count_。其它相同。
12.3 并发引用计数
SmartPtr中需要同时对count_和ptr_进行操作,在并发环境下这就意味着需要在操作前后加锁,来保证对两个对象的原子操作。
12.4 引用计数的性能
实现A中需要对原类进行修改,如果不能进行这种修改,则只能使用实现B。实现B中因为需要操作两个堆上的成员(count_和ptr_),创建和清除性能会比实现A差一些。
引用计数的收益是:
- 防止内存泄露。
- 高效的赋值操作。尤其是作为写时复制(COW)的重要环节,如果赋值后很少有修改操作的话,相比于深复制,引用计数的收益非常明显。
- 节省内存空间。尤其是体积非常大的对象。
- 可以方便的实现RAII。将引用计数的Detach操作变为某种关闭操作,则可很方便地实现RAII。
引用计数的坏处:
- COW中如果修改较多,那么性能相比深复制不一定有提升。
- 在并发环境下对它的操作还有锁的开销,可能会影响性能比较多。
下列条件会增加引用计数的收益:
- 目标对象消耗大量资源。
- 资源的分配和释放很昂贵。
- 目标对象高度共享。
- 引用的创建和清除很廉价。
第13章 代码优化
13.1 关注点
应用程序编码阶段会引入很多的性能问题,这类问题通常是小范围的问题,解决它们不需要看太多的代码,也不需要改变深层次的设计,但有可能会带来比较明显的性能提升。
13.2 缓存
记住频繁计算和计算代价高的计算结果。比较典型的是将在循环中需要反复计算的固定结果保存在循环外的一个变量中,并在循环中使用这个变量。
13.3 预先计算
可以将一些在关键路径上需要频繁用到的计算结果提前进行计算,将结果保存起来,这样真正使用时只需要简单的查找就可以了。
13.4 降低灵活性
如果目标代码使用的范围很固定,那么就不需要在代码中考虑太多的通用情况,而是可以针对目前已知的一些特定情况进行大胆的假设,从而加快运行速度。
13.5 提高常用路径的速度。
80%的时间消耗在20%的函数调用上,因此尽量降低这20%的函数需要的时间就能大大提高整个系统的性能。
相似的例子出现在if (and1 && and2)以及if (or1 || or2)中,如果两个条件没有依赖关系,那么就将更有可能决定整个关系式值的条件放在前面,即如果and1比and2更容易为false,那就将and1放在前面,而如果or1比or2更容易为false,就将or2产在前面。
除了条件值的可能性外,还可以将每个操作的指令数也考虑进去,则可以令整个条件式指令数最小的条件放在前面。
而如果所有外部参数中有5%是特殊的,其它95%是类似的,那么我们可以单独为这5%的特殊参数设计一个路径,从而加快95%的常见情况的处理速度。
13.6 缓式计算
将计算延迟到真正需要的时候,从而避免昂贵的计算结果最后没被使用。这节没什么新东西。
13.7 无用计算
这节没什么新东西。
13.8 体系结构
在设计对象布局时考虑到体系结构的影响,主要是系统缓存带来的影响。如矩阵的行长度如果恰好和缓存行长度相等,那么在进行矩阵转置时会出现频繁的缓存未命中。而在设计经常需要一起访问的两个成员时,最好让它们可以处于同一缓存行中,这也需要让先被访问的成员放在前面。
13.9 内存管理
性能是一种交易。没什么新东西。
13.10 库和系统调用
很多性能细节都隐藏在库和系统调用的背后,因此在设计时要详细了解这些细节,并在多个可用的工具中选择功能刚刚好够用的那个,它的性能往往也要比那些功能更加完善强大的版本好一些。
13.11 编译器优化
在release时开启编译器优化,可能会有很大的性能提高。
第14章 设计优化
14.1 关注点
设计上的优化是全局的,依赖于其它组件和代码。
14.2 设计的灵活性
在软件开发的早期,如果不了解程序的热点,那么就全面使用STL好了。当对程序的运行有一定了解后,可以用一些灵活性去换取性能。
14.3 缓存:时间戳
web服务中每次请求都需要写入日志,并带有一个时间戳。如果单次请求需要多次写入,那么可以将计算出来的时间戳缓存起来供所有这些日志写使用。
14.4 缓存:数据扩展
如果对象经常需返回某个操作的值,那么可以将这个值内嵌在对象中,如各种容器的size等。
14.5 缓存:公用代码陷阱
如果某段代码每次都需要判断请求的类型来决定运行路径,那么可以将它拆成两段代码分别做单一的操作。这是虚函数很擅长的领域。
14.6 高效的数据结构
没什么新东西。
14.7 缓式计算、无用计算和失效代码
没什么新东西。
第15章 可伸缩性
15.1 关注点
并行或并发环境下的性能问题。
15.2 SMP体系
SMP体系的一个性能瓶颈是多个处理器需要共享与内存间的总线。
解决方案是每个处理器配一个大的缓存,但带来的主要问题是缓存一致性问题。
以上两个问题导致了实际的并行性能提升难以达到核心数量提升的倍数。
15.3 Amdahl法则
顺序计算是通往可伸缩性道路上的主要障碍。单独加速某一段带来的提升不会大于这段所占的总开销比例。
15.4 分解任务
把单一的任务分解为多个并发子任务可以提高以下指标:
- 请求的响应时间。
- 服务器的吞吐量。
- CPU使用率。
I/O密集型任务更适合并发执行。
15.5 缓存共享数据
例子:某线程服务于某请求,在线程生命期内,线程的许多操作都要作用于该请求之上。一种思路是在每个操作处调用pthread_getspecific,但这会带来严重的锁开销。另一种思路就是在线程开始时获得一次指针,并传给随后的所有函数。
15.6 无共享
上例中,更好的方法是直接将线程相关的东西放到与线程关联的结构中,这样可以完全地去掉需要串行化的部分。
15.7 部分共享
在不知道请求数量的时候,可以用固定大小的线程池来进行服务,这样有着很好的伸缩性。
15.8 锁的粒度
通常,把多个无关的资源融合到单个锁的保护之下不是个好主意。例外是满足以下两个条件的情况:
- 所有的共享资源总是一起被操作。
- 其中没有消耗大量CPU时间的操作。
锁的粒度太粗会导致并行性下降,而粒度太细又会导致锁的开销增加、以及单个任务的处理时间增长。
15.9 伪共享
SMP系统上,两个锁如果处于同一缓存行中,那么p1对m1的锁操作会导致整个缓存行在p2上失效,从而导致p2访问m2时要重新读内存。避免这个问题的方法是手动在m2和m2间插入一定的空白。
15.10 惊鸟
如果用多个线程accept,比如100个,那么在来连接请求时,100个线程都会醒来,但只有1个线程能获得请求,其它99个线程转而继续睡眠。这种CPU冲击会导致服务器萎缩并严重损害吞吐量。当吞吐量下降时,系统可能会增加更多的线程,从而导致问题更加严重。
解决问题的方法是只用一个线程accept再将请求分发给其它线程。
15.11 读写锁
没什么新东西。
第16章 系统体系结构相关性
16.1 关注点
本章讨论的东西只简单的罗列如下:
- 内存层次:从寄存器、L1到L2、内存、硬盘,访问延迟成数量级的增加。其中寄存器的带宽又比L1大很多。
- 正确地让变量基于寄存器可以使某些编译器产生的个别方法在性能上提高一个数量级。放在寄存器中的变量在调用方法时要进行保存,但省去了每次访问时载入到寄存器中的开销。
- 磁盘中的数据一般是用B+树来保存的,为的是减少寻道次数。
- 编写代码时多考虑数据的局部性,可以有效地减少缺页中断发生的频率,从而大幅提高性能。
- C++中同一namespace的代码往往属于同一编译单元,因此相互的局部性更好。因此组织代码时按照namespace而不用按照文件名来进行。
- 性能足够的时候,永远选择更简单更简短的解决方案。复杂性是正确性和可维护性的敌人。
- 如果SMP中多个处理器都要读或写同一缓存行,P1写后其它处理器都要等待缓存更新完毕才能进行读,这种等待称为缓存颠簸。
- 带有大量跳转的短代码序列要比不带跳转的长代码序列更费时间去执行。
- 同上,简单的计算胜过小的分支。
- 多线程的程序,如果各个线程间的独立性不好,有着大量的共享资源,那么因为锁的开销+线程上下文切换的开销,可能导致其性能还不如单线程程序。
- 上下文切换有三种主要代价:处理器上下文转移、缓存和TLB丢失、调度开销。其中缓存的影响可能最大。
- 同步操作的开销往往比异步操作大得多,但异步操作往往需要用轮询来获取结果,根据需要再进行权衡。
- 对于要求响应延迟最小的程序来说,同步多线程方案要比异步轮询方案好,因为异步方案缺少通知手段。
郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。