COM线程模型-套间(新)

12 篇文章 0 订阅

严重声明:
[原]crybird如有转载请注明出处。
今天偶然看到,我10年前对COM的心得笔记,竟然有不止一人引用,倍感欣慰。于是,把现在的心得加以补充。
本故事纯属虚构,只想以轻松的心情理解COM,别无他意(对各方没有任何不友好、不尊重之意,谢谢理解)。

【某外包公司的事故】

背景,COM规范太复杂了,按规范编写服务组件和客户端调用,代码量太大,快没人用了。。。

第一回~[主线程内创建和调用]
组长:今天上头交给个任务,让咱们简化一下COM编程。别人调用我们的库,可以少写代码。
码农:咋办?
组长:加一些规则,别人必须按咱们的标准写代码。名字就叫COM库,让他们以为咱们是标准。
码农:今天晚上我要陪女友K歌,没时间加班啊,老大?
组长:恩,简单。规则就是,只允许在主线程创建对象和调用其函数。另外,加个初始化函数、反初始化函数。弄个第一版。
码农:也就是~~咱们啥也不干?
组长:不是有俩函数吗?!再增加个套间的概念吧,把主线程中两个函数之间的部分叫套间,写材料宣传下。我先走了,今晚陪老婆K歌,迟到要挨骂。
码农:老大慢走~
组长:对了,项目结束后,款收一下。

第二回~[子线程调用主线程]
码农:老大,有人不按规则来,没调初始化、反初始化函数!
组长:太没面子了,弄个随机数,奇数正常执行,偶数返回错误,玩死他。
码农:有人要求跨线程调用组件函数,咋办?
组长:创建个隐藏窗口,跨线程调用包装成“消息”发送到主线程。所有调用排队,省的咱们同步,安全又方便。
码农:OK,刚才上面说让咱们做工作总结。
组长:把现在的模式,叫single-thread-per-process。高大上吧!
码农:要不要挖个坑?
组长:老规矩!跨线程调用的,让他们调用散集、列集函数。否则咱们工作量体现不出来。二期结束!准备收款。

第三回~[多线程并行,无交互]
码农:老大,有人反映COM太慢。
组长:恩,都在一个主线程排队调用是有点慢。这样吧,任何线程,只要有消息队列,都可以创建COM对象。调用的时候,都发消息到创建组件的线程里那个窗口。
码农:就是说允许多线程一起运行了呗~多排几个队伍显得快~
组长:对,起个名字装装13,叫STA套间模式吧。之前的版本只有一个STA,现在的版本允许多个STA,这样就统一了。搞完后,写成报告发我。
码农:也就是。。。
组长:三期结束!收款!

第四回~[多线程并行,有交互]
组长:检查组要来了,据说要查代码。
码农:咋办?
组长:申请四期,休息够了吧,得干活了。
码农:怎么改?
组长:整一个多线程套间概念,就叫MTA吧。初始化函数加Ex,容易和以前区分。把创建的、调用的、相关的线程,都搞一起去,真正执行代码的时候切换一下就行了。
码农:同步咋办?
组长:让开发组件的人自己搞,咱们只负责执行环境的切换。
码农:老大,这个周末。。。
组长:干活,没加班费。午餐请你吃大饼卷馒头,就着白米饭,管够。

第五回~[概念讨论]
码农:组长啊,套间概念STA和MTA有点混乱。一个程序可以有几个STA,几个MTA?
组长:随便几个STA都行,可以任意创建隐藏窗口和消息循环。其余的全搞到MTA,执行代码的时候统一切换,所以MTA就一个。
码农:哦,要不要挖坑?
组长:嘿嘿,你懂我。STA、MTA,限定在套间概念。加俩线程概念做宣传,STA的线程叫套间线程Apartment Thread,MTA相关的线程,叫自由线程Free Thread。
码农:用户组件的分类概念呢?
组长:用户编写的COM组件按如下分类:允许在主线程套间里运行的叫Single;允许多个单线程套间的叫Apartment;允许多线程套间的叫Free;都允许的叫Both。以后扩展了,另说。
码农:Single和Apartment好像差不多诶。
组长:没事,历史原因,还说明咱们的库复杂。
码农:有些概念有些重复和模糊唉,比如Apartment。
组长:没事,这些概念,不研究也没事,按规则来就行;研究的就让他晕去吧,不能让他们知道我们就是切换线程环境之类的,否则咱们的黑科技就是白科技了。
码农:恩,这样就明白了。那。。。如果开发组件服务的人把Both声明成Single呢?
组长:能照常用就行,管那么多?
码农:如果把Single声明成Free呢?
组长:运行错误是他没按规则来,关你P事?
码农:Single声明成Free但以Single形式调用?Both声明成Apartment但以Free形式调用?。。。
组长:滚~开发的没按声明开发,怪开发;调用的没按声明调用,怪调用。锅是他们的,咱不背。
码农:好吧,不管了,爱咋咋地。就一条,凡是不按规则来的,结果未定义。

第六回~[NA套间,自然套间、中立套间,竟然被某软翻译成非特定语言]
码农:老大,有人反映咱们调用COM切换环境的时候,开销大。
组长:这样吧,增加一个NA/TNA中立线程套间,COM改为COM+。另启动一个项目,这个不批钱了。
码农:这个和线程咋搞?
组长:干脆不和线程搞一起了,只和组件对象搞一起吧。
码农:唉,感觉越来越复杂了。。。
组长:不错了。第一个项目分了四期,这个是新项目了。继续加油哦~~

第七回~[项目组总结]
组长,码农:
尊敬的各位领导,各位来宾,大家晚上好,因为着急领奖金,我们就不扯犊子了~~
其实,COM库背后做了很多很多很多很多的工作,尽管概念多了些,乱了些,不太容易理解。
下面是其他一些网友(earthdog、LOP、蜗牛)的对COM库的理解,
我们认为很好的,引用一下,作为我们致辞的结束:

[earthdog:]
首先说一下Apartment的定义吧:
Apartment是指COM组件运行所在的逻辑上下文。每一个COM对象严格地在一个apartment中运行。组件内的属性和方法仅可以被属于apartment地线程调用。一般说来,有如下几种:
1.single
在该模式中,COM强制所有的调用都来自于在一个主线程apartment(主STA)的所有组件对象实例。组件的所有实例在同一个apartment中运行,这个apartment就是主STA。这样的话,组件的调用是串行化的,在组件中同时只有一个线程执行
2.Apartment
通常所说的STA模型。提供一个在单线程上的一级并行处理。每一个对象的实例同时仅可以被一个线程访问,但不同对象上的请求可以并行执行。在这种情况下,实例成员可以不用同步,但是访问共享的静态数据时要考虑同步
(这个同Single是有区别的,一个进程可以有多个STA,但是只能有一个主STA和一个MTA,Single只能在主STA中运行,但是该模型可以在所有的STA中运行)
3.Free
通常所说的MTA模型。在这种情况下,多线程可以一个apartment中同时运行,所有的执行都可以是并行的。但是该模型的类仅能在MTA中运行,不能在STA中运行。而且所有的同步都需要你进行处理
4.Both
可以简单认为是Apartment和Free的结合。当客户端程序运行在STA中,组件运行同Apartment,当客户端运行在MTA中,组件运行同MTA

[LOP:]
Apartment被翻译成套间或是单元,是线程模型的一个实现者,就象在操作系统课程中讲到的线程只是一个数学模型,而Windows的线程、进程是它(数学模型的线程)的实现者。而线程模型共有三种:STA(单线程套间)、MTA(多线程套间)和NA/TNA(中立线程套间,由COM+提供),也就是说COM运行时期库(注意,不是COM规范,以下简称COM)提供的套间共有三种,分别一一对应。而线程模型的存在就是线程规则的不同导致的,而所谓的线程规则就只有两个:代码是线程安全的或不安全的,即代码访问公共数据时会或不会发生冲突。由于线程模型只是个模型,概念上的,因此可以违背它,不过就不能获得COM提供的自动同步(序列化)调用的好处了。
下面就先说明各个类型的线程模型,再说明COM运行时期库是如何实现它们的,就象说明Windows是如何实现线程这个数学模型的一样,最后指明一下跨套间调用和各种类型套间编写的要求以帮助理解。希望楼主对于Windows操作系统的线程这个概念相当熟悉,对何谓“线程安全的”亦非常了解。

STA——一个对象只能由一个线程访问(通过对象的接口指针调用其方法),其他线程不得访问这个对象,因此对于这个对象的所有调用都是同步了的,对象的状态(也就是对象的成员变量的值)肯定是正确变化的,不会出现线程冲突而导致的对象状态错误。其他线程要访问这个对象,必须等待,直到那个唯一的线程空闲时才能调用对象。注意:这只是要求、希望、协议,实际是否做到是由COM决定的。如上所说,这个模型很象Windows提供的窗口消息运行机制,因此这个线程模型非常适合于拥有界面的组件,像ActiveX控件、OLE文档服务器等,都应该使用STA的套间。
MTA——一个对象可以被多个线程访问,即这个对象的代码在自己的方法中实现了线程保护,保证可以正确改变自己的状态。这对于作为业务逻辑组件或干后台服务的组件非常适合。因为作为一个分布式的服务器,同一时间可能有几千条服务请求到达,如果排队进行调用,那么将是不能想象的。注意:这也只是一个要求、希望、协议而已。
NA/TNA——一个对象可以被任何线程访问,与MTA不同的是任何线程,而且当跨套间访问时(后面说明),它的调用费用(耗费的CPU时间及资源)要少得多。这准确的说都已经不能算是线程模型了,它是结合套间的具体实现而提出的要求,它和MTA不同的是COM的实现方式而已。

套间只是逻辑上的一个概念,实现时只是一个结构(由COM管理)而已,记录着相关信息,如它的种类(只能是上面那三个,至少现在是),并由COM根据那个结构进行相应的处理。下面说明这三种套间的实现方式:

STA套间——一个套间如果是STA,那么那个套间有且只有一个线程和其关联,有多个对象或没有对象和其关联,就象有多个线程和一个进程关联一样,也就是说套间那个结构和某个线程及多个对象之间有关系,关系具体是什么由COM说得算,幸运的是COM正是按照上面的线程模型来定义互相之间关系的。根据上面的算法,很容易就知道只有这个线程可以访问这个套间里的对象。
COM是通过在线程中创建一个隐藏窗口,然后外界(这个套间外的线程)对这个对象的调用都转变成对那个隐藏窗口发送消息,由于Windows自己提供的消息机制正好跟STA很像,因此在这里被COM拿来利用。至于COM如何截获外界对对象的调用,等会再说明。因为COM利用消息机制来实现STA,因此STA套间里的线程必须实现消息循环,否则COM将不能实现STA的要求。

MTA套间——这种类型的套间可以和多个线程及多个或没有对象相关联。根据上面的MTA模型,可知只有这个套间里的线程才能访问这个套间里的对象,和STA不同的只是可以多个线程同时访问对象。
外界(不属于这个套间的线程)对这个套间里的对象的调用将会导致调用线程(外界,也就是STA,因为NA没有线程)挂起,等待这个套间里的某个线程来调用那个对象的方法。对象返回后,调用线程被唤醒,继续运行。反过来,MTA的线程访问STA里的对象时,COM将把调用转换成对STA线程里那个隐藏窗口的一个消息发送(通过列集),返回后再由COM转成结果(通过散集,列集的反操作)返回给MTA的线程。因此STA和MTA都是只能由它们关联的线程调用它们关联的对象。而根据上面所说,当MTA调STA或STA调MTA,都会发生线程切换,也就是说一个线程挂起而换成执行另一个线程。这是相当大的消耗(需要从内核模式向用户模式转换,再倒转好几回),而NA就是针对这个设计的。

NA套间——这种套间只和对象想关联,没有关联的线程,因此任何线程都可以访问里面的对象,不存在STA的还是MTA的。
外界(其实就是任何线程)对这个套间里面的调用都不需要挂起等待,而是进入套间,直接调用对象的方法,但是是通过一个轻量级代理(相对于STA和MTA的跨套间调用)来调用的。而那个轻量级代理会根据调用线程所在套间的种类自动决定调用方法:是STA套间,将调用转成消息发送给调用线程;是MTA套间则什么事都不发生。由于可能会多个线程同时访问NA套间的对象,因此和MTA一样,其不能有线程相关性(后面说明)。

根据上面所说,STA其实和MTA逻辑上是完全一样的,只是一个是关联一个线程,一个是关联多个线程而已。但把它们分开是必要的,因为线程安全就是针对是一个线程还是多个线程。而NA之所以不关联线程是因为它的目的是消除上面跨套间调用时产生的线程切换损耗,关联线程没有任何意义。
COM强行规定(你不遵守也没辙,因为全是COM实现套间的,你根本没有插手的余地)一个进程可以拥有多个STA的套间,但只能拥有一个MTA套间和一个NA套间,我想这应该已经很容易理解了(要两个MTA套间或NA套间干甚?)。


线程在进行大多数COM操作之前,需要先调用CoInitialize或CoInitializeEx。调用CoInititalize告诉COM生成一个STA套间,并将当前的调用线程和这个套间相关联。而调用CoInititalizeEx( NULL, COINIT_MULTITHREADED );告诉COM检查是否已经有了一个MTA套间,没有则生成一个MTA套间,然后将那个套间和调用线程相关联。接着在调用CoCreateInstance或CoGetClassObject等创建对象的函数时,创建的对象将以一个特定规则决定和哪个套间相关联(后叙)。这样完成后,就完成了线程、对象和套间的关联(或绑定)。
前面提到的决定对象去向的规则如下。

当是进程内组件时,根据注册表项ThreadModel(可能记错了)和线程的不同,列于下:
创建线程关联的套间种类 ThreadModel 组件最后所在套间
STA Apartment 创建线程的套间
STA Free 进程内的MTA套间
STA Both 创建线程的套间
STA “”或Single 进程内的主线程套间
STA Neutral 进程内的NA套间
MTA Apartment 新建的一个STA套间
MTA Free 进程内的MTA套间
MTA Both 进程内的MTA套间
MTA “”或Single 进程内的主线程套间
MTA Neutral 进程内的NA套间
进程内的主线程套间是进程的主线程(必须是主线程,即第一个线程)关联的套间。后面说明为什么还来个进程内的主线程套间。
当进程外组件时,由主函数调用CoInitializeEx/CoInititalize指定组件所在套间,与上面的相同,CoInititalize代表STA,CoInititalizeEx( NULL, COINIT_MULTITHREADED );代表MTA,没有NA。因为NA是COM+提供的,而COM+服务只能提供给进程内服务器,因此只使用上面的注册表项的规则决定dll组件是否放进NA套间,而没有提供类似CoInititalizeEx( NULL, COINIT_NEUTRAL );来处理exe组件。而且如果可以使用CoInititalizeEx( NULL, COINIT_NEUTRAL );将导致调用线程和NA套间相关联了,违背了NA的线程模型,这也是为什么ThreadModel值在<组件的CLSID>\InprocServer32键下。

下面说明跨套间调用对象(其实也就是跨线程调用对象)。
STA线程1创建了一个STA对象,得到接口指针IABCD*,接着它发起STA线程2,并且将IABCD*作为线程参数传入。在线程2中,调用IABCD::Abc()方法,成功或者失败天注定。由于线程2在STA套间中,不同于线程1所在的STA套间,这样线程2就跨套间调用另一个套间的对象了。按照前述的STA规则,IABCD::Abc()应该被转成消息来发送,而如果如上面做法,可以,编译通过,不过运行就不保证了。
COM之所以能够实现前面所说的那些规则(STA、MTA、NA),是因为跨套间调用时,被调用的对象指针是指向一个代理,不是对象本身。而那个代理实现前述的那三个实现算法(转成消息发送,线程切换等)。而按照上面直接通过线程参数传入的指针是直接指向对象的,所以将不能实现STA规则,为此COM提供了如下两个函数(还有其他方式,如通过全局接口表GIT)来方便产生代理:

CoMarshalInterThreadInterfaceInStream()和
CoGetInterfaceAndReleaseStream()。
现在重写上面代码,线程1得到IABCD*后,调用CoMarshalInter…()得到一个IStream*,然后将IStream*传入线程2,在线程2中,调用CoGetInterf…()得到IABCD*,现在这个IABCD*就是指向代理的,而不是对象的了。
因此,前面所说过的所有线程模型的算法都是通过代理实现的。要跨套间时,使用上面两个函数将接口列集到一个IStream*中,再通过其他手段将IStream*传到要使用的线程中,再用GetInterf…()散集出接口以获得指向代理的指针。因此之所以要获得代理的指针是因为想使用COM提供的线程模型,如果不想使用大可不必这么麻烦(不过后果自负),并没有强制要求必须那么做。
当线程1和线程2都是MTA时,则可以象最开始说的那样,直接传递IABCD*到线程2中,因为MTA线程模型同意多个线程同时直接调用对象,而那个对象通过某种形式(如ThreadModel = Free)向COM声明了自己支持MTA线程模型。

如前面所说,COM的套间机制要成功,必须服务器(组件)、客户和COM运行时期库三方面合力实现,其中有任何一方不按着规矩来,将不能实现套间机制的功能,不过这并不代表什么错误,套间机制不能运作并不代表程序会崩溃,只是不能和其他COM应用兼容而已。
比如:对象中的属性1在你设计的算法中肯定不会被两个以上的线程写入,只是会被多个线程同时读出而已,因此不用同步,可以用MTA,但对象的属性2却可能被多个线程写入,因此你决定使用STA。因此在客户端,通过前面说的CoMarshalInter…()和CoGetInterf…()将对象指针传到那个只会写入对象的属性1的线程,其实这时就可以直接将对象指针传到这个线程,而不用想上面那样麻烦(而且增加了效率),但是就破坏了COM的套间规矩了——两个线程可以访问对象,但对象在STA套间中。所以?!!什么事都不会发生,因为你已经准确知道你的算法不会捅娄子(线程访问冲突),即使破坏COM的规矩又怎样?!而且组件仍可以和其他客户兼容,因为不按规矩来的是客户,与组件无关。不过如果组件破坏规矩,那么它将不能和每一个客户兼容,但并不代表它和任何客户都不兼容。这里其实就是客户和组件联合起来欺骗了COM运行时期库。
我只是想说明规则是拿来用的,不是拿来栓自己的。客户要做的工作前面已经说过了(那两个函数或全局接口表或其他只要正确的方式),下面说明组件应该做的工作。组件可以存在在四个套间中(多了一个主线程套间),所需工作分别如下:

STA——当一个组件是STA时,它必须线程保护全局变量和静态变量,即对全局变量和静态变量的访问应该用临界段或其他同步手段保护,因为操作全局和静态变量的代码可以被多个STA线程同时执行,所以那些代码的地方要进行保护。比如对象计数(注意,不是引用计数),代表当前组件生成的对象个数,当减为零时,组件被卸载。此变量一般被类厂对象使用,还好ATL和MFC已经帮我们实现了缺省类厂,这里一般不用担心,但自定义的全局或静态变量得自己处理。

主STA——与STA唯一的不同是这是傻瓜型的,连静态和全局变量都可以不用线程保护,因为所有不是安全访问静态和全局变量的对象都通过主线程的消息派送机制运行,因此不安全的访问都被集中到了一个线程的调用中,因而调用被序列化了,也就实现了对静态和全局变量的线程保护。至于为什么是主线程,因为进程被创建的时候一定会创建主线程,所以一定可以创建主STA。因此主STA并不是什么第四种套间,只是一个STA套间,不过关联的是主线程而已,由于它可以被用做保护静态和全局变量而被单独提出来说明。因此一个进程内也只有一个主STA套间。

MTA——必须对组件中的每个成员和全局及静态变量的访问使用同步手段进行保护,还应考虑线程问题,即不是简单地保护访问即可,还应注意线程导致的错误的操作,最经典的就是IUnknown::Release().

DWORD IUnknown::Release()
{
DWORD temp = InterlockedDecreament( &m_RefCount );
if( !temp ) // 不能用m_RefCount,原因楼主还是自想吧
delete this; // 因此不是只要用原子访问函数保护了m_RefCount的访问就行了
return temp; // 前面对全局变量的保护也和此类似,要考虑线程问题
} 如果楼主对自己多线程编程的技术没有信心,建议最好不要编写可以存在于MTA套间的组件,不过就不能获得MTA的高性能了。
在编写MTA时还应该注意到线程相关性。没有线程相关性是指没有任何线程范围的成员变量,比如线程局部存储(TLS,Windows提供的一种机制,即分配的一块内存,这块内存只能被执行的线程所访问到,即使指向这块内存的指针是p,但不同的线程调用i = *p;将会将不同的值存到i中。不过这块内存一般很小,几十个四字节,所以一般new一块内存,然后将地址放到那四个字节中,就相当于每个线程都有一块属于它们的私有内存空间)。
也就是说在MTA中不能保存任何记录着TLS内存的指针,将没有意义(比如A线程记录的内存空间对B线程来说是无效的,因为TLS构造了一个线程相关的内存空间,就象每个进程都有自己的私有空间)。而不幸地MFC在它的底层运作机制的实现中大量使用了TLS,如模块线程状态、线程状态等。正是由于这个原因,MFC不能编写在MTA中运行的组件。

NA——前面关于NA的实现那里说错了:“是STA套间,将调用转成消息发送给调用线程;是MTA套间则什么事都不发生”,应该是当NA对象里有个对指向一个STA对象的指针的调用时,将会将调用转成向被调用的STA对象的关联线程发送消息,如果此时调用NA对象的是另一个STA线程或MTA线程,照样会发生线程切换。同理,如果那个对象是MTA的,而不是STA的,依旧发生线程切换(只有当调用线程是STA线程时)。不过处此以外的大多数情况(即不在NA对象的方法中调用另一个套间对象的方法)都不会发生线程切换,即使出现上面的情况也只有必要(MTA调NA再调MTA就不用切换)才切换线程。
在编写运行于NA套间的组件时,只需满足MTA的要求即可,上面的NA实现将由它的轻量级代理和COM运行时期库共同完成,程序员不用操心。

前面提到过有一种进程内组件可以被指明为Both的ThreadModel,这种组件很象NA,哪个套间都可能直接访问它,但只是可能,而NA组件是可以,这点可以从前面的那个进程内组件所属套间的规则中看出。这种组件可以支持一种称作自由线程汇集器的技术,这点很简单,楼主还是自己看书吧。当Both的组件使用了自由线程汇集器时,除了满足MTA的要求以外(上面所说的线程安全保护和没有线程相关性),还要记录传进来的接口指针的中立形式(比如IStream*,可以通过CoMarshallInter...()或GIT得到),以防止对客户的回调问题,具体原因楼主还请参考相关资料。

最后只是提醒一下,有3个STA套间,STA1、STA2和STA3,STA1用CoMarshallInter...()得到的IStream*传到STA2中被GetInterface...()得到的代理和在STA3中得到的代理不同,不能混用。因为当STA2和STA3调用在STA1的对象时,STA1如果回调(连接点技术就是一种回调)调用者,则STA2和STA3的代理能分别正确的指出需要让哪个线程执行回调操作,即向哪个线程发送消息,因此不能混用。 

[蜗牛:]
1.线程模型是干嘛用的?
解决”多个线程”“同时”调用你的COM组件的并发控制。客户没有你的COM的源代码,它不知道你的组件是怎么写的,是不是线程安全的(是否用CriticalSection或Mutex保护了临界资源),所以要有一种机制来声明组件的线程安全性,你开发时指定了组件的线程模型,客户端一看,哦,它就知道该怎么写调用的代码。
2.啥时候用操心线程模型?
全看客户端是单线程还是多线程,单线程不用操心,怎么调都没事,多线程就来事了,跨线程调用时就要考虑。
3.线程模型的一堆概念都是哪跟哪啊?
首先一分为二:客户端和组件。客户端用CoInitializeEx进入一种套间,Apartment、Free是指的客户端进入的套间种类;组件要向注册表写入自己兼容什么样的客户端套间,是Apartment,还是Free,还是两种都兼容(Both)。
4.客户端的线程套间和组件的不一致了咋办,难道我调个COM组件还得查注册表看看兼容什么线程模型?
不一致时以组件为主。客户端建的是Apartment,组件兼容Free,那COM背地里会在客户端建一个Free套间,把组件放进去。反之,会建一个Apartment套间把组件放进去。总之以组件为主,这是关键,只有这样,你才不用关心组件的线程安全性,COM服务替你在后台办妥了。
5.听说过列集这个名词,是什么啊,啥时候用?
记死了,跨线程调用组件就得列集,没的商量。传出接口的线程列集,使用接口的线程散列。列集说白了就是不让你直接调用组件的接口,而是调用接口的代理。COM服务在中间插一杠子,干啥,实现COM线程安全那一揽子事呗,它不拦截你的调用它怎么实现啊,所以就给你个代理,所以就列集了呗。

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值