• Direct3D Draw函数 异步调用原理解析


    概述

    在D3D10中,一个基本的渲染流程可分为以下步骤:

    • 清理帧缓存;
    • 执行若干次的绘制:
      • 通过Device API创建所需Buffer;
      • 通过Map/Unmap填充数据到Buffer中;
      • 将Buffer设置到DeviceContext中;
      • 调用Draw执行绘制过程;
    • 调用Present提交渲染结果。

    在这一过程中,不被初学者注意、然而在深入学习时定会遇到的一个特性是:D3D的Draw函数是一个异步调用。

    我们知道,实际渲染的过程大部分是在GPU上完成的,CPU只负责发号施令。实际上,数据准备完成后,当你的程序调用了Draw函数后,CPU才会真正的将数据和命令提交到GPU上进行渲染。从命令提交到渲染完成通常需要数十毫秒的时间,甚至对于复杂的程序更是需要数秒的时间才能返回。如果Draw一直等到GPU渲染完成再返回并执行剩下的代码,那显然整个线程的时间都浪费在了等待GPU的结果上。

    这个问题或许可以利用多线程编程来解决,但是这也意味着你的程序更加复杂了。所以在D3D中,Draw将命令发送给显卡之后立即返回,你的程序便可以接着做其它工作了,例如新渲染数据的准备、物理、逻辑、AI的计算、场景的优化等等。换句话说,我们称Draw是一个异步调用

    相信对D3D有所了解的人这一机制都已熟记于心。本文的内容,就是讨论这个“异步调用”是如何实现的。具体的内容包括:

    • 描述异步调用机制的基本实现方法;
    • 梳理用户代码和GPU对资源的操作(Map,Unmap),以及他们之间可能产生的相关性;
    • 介绍一种可以保证异步和并行化结果正确的方法;
    • 讨论异步调用时错误的处理。

    这些内容可以帮助你理解Draw调用的实现原理,另一方面也可以作为你实现其他异步调用API的参考。需要说明的是,本文所述的大部分机制,均是由显卡驱动程序或D3D Runtime实现,但考虑到各家驱动实现不一以及版权和保密协议,本文所提供的方法没有参考任何实际的驱动程序和MS提供的参考代码,而以SALVIA渲染器正在开发中的代码为主要参考。

    我们将先引入Producer/Consumer这一经典异步模型作为异步调用实现的基础;其次我们介绍一些保证并发程序正确性的一些常识;再来会介绍我们在Producer/Consumer的基础上所做的异步调用实现,并讨论如何解决CPU和GPU对同一份资源可能存在的访问冲突;在最后两节,我们会讨论跨线程的对象生命周期控制和检查,以及异步调用的错误处理机制。

    CPU与GPU的Producer/Consumer模型

    在Producer/Consumer模型中,最重要的角色有三个,产生命令和数据的Producer,执行命令和使用数据的Consumer,以及用于在Producer和Consumer之间传递消息的对象,这个对象通常是消息队列(Message Queue)。

    我们来看一下CPU和GPU和合作关系。CPU和GPU是两个独立执行的硬件设备,但是GPU的运行都是受到CPU控制的。GPU和CPU最基本的工作模式是:CPU将数据准备好后,提供给GPU,GPU进行计算、渲染并输出。有时候CPU也会从GPU处取得一些数据。可以看出,CPU和GPU是个很典型的生产者/消费者模型。对于实际硬件来说,CPU和GPU的关系可能是多级的Producer/Consumer结构。例如用户代码到驱动是一级,驱动到硬件又是一级。因此,消息队列可能同时存在于软件和硬件中。往往看起来简单的模型,在实践中就是这样复杂起来的。

    Draw调用到底做了哪些事情

    CPU和GPU的通信主要出现在两个时候:第一,读写资源(Map/Unmap);第二,Draw的调用。这些通信都会变成Driver发给显卡的命令。例如,我们假设COMMAND是个四字节的命令,每个COMMAND最长可以有512个字节的数据;我们要将Buffer传到GPU的某块内存上,那么我们就能把需要传输的数据处理成这样的指令组:

    COPY GPU_MEM_ADDRESS DATA_LENGTH DATA

    然后通过总线发送给GPU,GPU拿到了指令和数据后,执行单元就会把数据写到显存的相应位置。当然有了DMA的存在,真正的数据拷贝还是比这个要高效的多。

    除了往显存中写数据,还要给GPU提供一些状态。比如Vertex Buffer的地址,Index Buffer的地址,Texture的地址和行的Pitch,等等。可千万不要以为GPU中会保存一个ID3D10Buffer的对象,实际上到了GPU后,这些对象都只会变成最最原始的指针、和一些Bit位的开关。它们和对象之间的关系,都是由驱动程序来维护的。包括显存的分配、任务的安排和调度,都是驱动程序的责任。可以说,显卡的驱动程序几乎就是GPU的OS。这些状态,GPU中可以叫State Buffer,也可以叫Context,也可以叫Register File。总之怎么叫,那都是GPU设计公司的喜好了。

    除了数据、基本状态,剩下就是有动作的命令。比如Transform、Rasterize、Tessellate、Query,等等。这些命令传送到显卡之后,显卡就真正的开始干活了。

    说了这么多废话,总结一下就是:CPU发送给GPU的内容,可以粗浅的分为数据、状态和命令。那么这些内容都是什么时候被传输到GPU上的呢? 再说一句废话:只要数据在修改完毕后、使用之前传输到GPU上就可以了。那如果都开始渲染了,这些内容还没有传送完毕要怎么办呢?那渲染就只能等它们都传输好再开始工作。

    为了避免渲染程序等待数据传输,为了减少宝贵的总线带宽,CPU和GPU之间的通讯需要经过一定的优化。对于数据(Constant Buffer,VB/IB,Texture)来说,因为数量多,传输时间也比较长,因此可以在Unmap一结束就将数据提交给GPU;而对于状态和命令而言,数量比较小,可能会遭遇频繁的更改,同时还需要维护彼此间的一致性,因此这部分内容可以延期到非提交不可的时候再传送到GPU上。

    所谓非提交不可,就是执行Draw的时候。 Draw是实际执行绘制的函数。到了这里,绘制所需要的全部状态状态和数据都已经齐备,就只差Draw这个东风了。因此当Draw被调用的时候,除非硬件正忙,否则所有的工作没有理由再不进行了。此时就需要将渲染所需要的状态和命令在CPU上统计好,打包发送给硬件。在这一阶段,Draw需要完成很多工作,比如脏属性的检查以减少传输量,比如渲染状态的正确性和一致性检查等等,一般来说GPU命令的生成也可以放在这里完成。

    CPU/GPU资源读写相关性分析

    在D3D中,异步调用要求和同步调用的结果完全相同。但是因为异步调用的存在,前后函数的执行时间不再是严格的一前一后,而可会发生重叠(也就是并行)或重排(乱序)。这时就需要进行资源相关性的分析,确保并行或重排后的结果,与同步的、顺序执行的结果是一致的。

    写到这一段,我内心深处不由得回想起伟大的程序员KULA的教导:“算法就是构造一个数据结构,然后把数据插入到指定的位置。”遵循着文成武德KULA巨巨的教导,我们也可以这么认为:异步调用的正确性分析,就是对数据操作顺序正确性的分析。

    来看一下数据相关性分析的理论。流水线级的数据相关性分为四类:读后读(RAR),写后读(RAW),读后写(WAR)和写后写(WAW)。什么意思呢,就是说如果所有的指令都只对同一个数据是读操作,那这些指令随便怎么排序都是正确的;但是如果有写指令,那么写指令前后的读写操作,都不能随意调整位置。

    // 基本例子
    int a = 5;
    int b = 3;
    int c = a + b; // c = 8
    
    // 交换a和b的赋值顺序
    int b = 3;
    int a = 5;
    int c = a + b; // c = 8

    比如说在上面的代码中,a和b是不相关的两个变量,那么这两个值的操作相互之间没有影响。a和b的赋值谁先谁后,c的结果都没有变化。但是,如果我们把c的计算放在a和b的赋值之前,那么结果就可能会变化。这是因为c的计算中有a和b的读取,如果将a的读取和a的写入对调,那么结果就会和预期的有所不同。所以如果进行并行操作的话,两个赋值语句是可以并行完成的。但是隐含着读取的加法操作,必须在赋值语句(写操作)完成之后方可进行。这是写后读(RAW)的情况。

    其它情况也是类似的。 因此不管是读还是写,只要不违反上述对数据相关性的约束,那么它的结果就是正确的。当然对于并行编程而言,如果读写都针对同一个资源,那么还必须保证读或者写的操作是符合读写锁的互斥要求的。

    回到D3D10中,我们将D3D10的资源按照读写限制来分,一共有四种:

    image

    去掉细节不谈, 所有资源中最简单的当数Immutable,它的数据在初始化时就要确定,确定以后再也不能变动。所以不管Command的调用顺序如何,Immutable资源的数据都是不变的。所以Command的执行顺序,对于Immutable来说没有影响的;Default资源的读写操作局限于GPU内部,所以试图在GPU内部并发执行的命令需要进行的协调;Dynamic的读写横跨CPU和GPU,需要进行同步;Staging的情况最为复杂,但是它有一个限制,就是GPU上不会参与渲染或计算过程,只能用于Copy。

    要判断CPU和GPU的命令能否同时或异步执行、GPU命令内部能否同时执行,需要对命令流中前后命令的数据相关性进行考察。比如,CPU先让GPU进行渲染,然后再从GPU中读取一些东西。如果CPU将要读取的数据不是GPU要写的内容,那么CPU让GPU执行渲染后,就可以自顾自的读取数据了;但是如果它读取的内容恰好是GPU要渲染的内容,那CPU就只能等渲染结束才能读取了。甚至在数据相关性不高的时候,GPU还在渲染上一次调用,下一次调用就已经可以进入流水线了。说句题外话,我们这里所说的“Pipeline”和CPU还是有所不同的,流水的每一级都要工作很长时间,而且和下一级的在时间上的重叠度很高。是否需要通过前后渲染调用的重叠提高并行程度,在设计上需要进行取舍。

    我们来看一个例子:

    // Init idxBuffer and idxBuffer2
    
    devContext->IASetIndexBuffer(idxBuffer);
    devContext->Draw();
    
    devContext->IASetIndexBuffer(idxBuffer2);
    devContext->Draw();
    
    devContext->Map(idxBuffer2, READ);
    // Write idxBuffer2
    devContext->Unmap();
    
    devContext->Map(idxBuffer, WRITE);
    // Write idxBuffer
    devContext->Unmap();
    
    devContext->IASetIndexBuffer(idxBuffer);
    devContext->Draw();
    
    devContext->IASetIndexBuffer(idxBuffer2);
    devContext->Draw();

    如果我们用表格把代码中命令和资源的关系表达出来就是:

    image

    接下就是要如何解决异步编程中两个重要问题:1. 调用次序能不能颠倒;2. 被调用函数和调用方能不能同时执行。解决这两个问题的最基本的办法是拓扑排序。拓扑排序的作用是确定一条命令会对哪些命令产生依赖。如果它依赖的命令都执行完了,那么就可以执行这条命令了。当然在拓扑排序之前,首先要构造一张依赖图。依赖图的顶点是一条Command,是两个节点间的依赖关系。这一依赖关系可以由命令间的资源相关性得到:

    image

    Draw0和Draw1借助命令队列可以实现用户代码一侧的异步调用。但是根据这个图可以知道,Draw0和Draw1到了驱动之后,因为两个调用在Render Target上有一个顺序关系,所以驱动只能先执行Draw0;等执行完了,再执行Draw1。当Draw0和Draw1的异步调用被发起后,可能GPU还没有执行Draw0和Draw1,但是因为Map0是可以立即执行的;而第二个Map1就惨了,因为它要写Draw1用到的Index Buffer,如果Draw1正在画,那就是写冲突,如果Draw1还没画,Map1就把新数据写上了,那Draw1的结果就不是预期的了。所以Map1只能老老实实的等着Draw1绘制完毕。

    如果我们用拓扑排序的概念来解释,那就是Draw1是Draw0的后继,所以要等Draw0结束Draw1才能开始执行;Map1和Draw2是Draw1的后继,所以只有Draw1绘制完毕,才能考虑绘制Map1和Draw2。当然因为Draw2又依赖Map1,所以如果这个依赖没有消除的话(就是Map1对Index Buffer的写操作结束),Draw2也没办法正常执行。

    不过对所有命令利用资源的读写相关性构造拓扑排序是个比较大的消耗。因此在SALVIA的原型中实现了它的变种:我们建立了一个Command队列。队列中的每个Command都有一个被锁的资源计数;此外还有一个资源-命令队列表,表中每个资源都有一个关联命令队列:当一条Command执行完、或者没有任何Command执行的时候,都会根据Command使用结束的资源,去解除一部分命令的资源锁定。当一条Command所有的资源都不锁定时,Command就可以被执行了。

    具体的代码可以参见这里:

    class CommandLock
    {
        ResourceAccessType  access;
        uint32_t            lockedResourcesCount;
    };
    
    class ResourceLock
    {
        deque<commandlock*> lockedCommandLocks;
        ResourceAccessType  lockingAccess;
        uint32_t            lockingCount;
    };
    
    class Queue
    {
    public:
        void PushCommand(Command* cmd)
        {
            {
                lock mutexLocker(mMutex);
                mProducerCond.wait(mutexLocker, [this](){return !this->mCommmands.full(); });
                
                for(auto res: cmd->Resources() )
                {
                    auto iter = mResourceLocks.find(res);
                    if ( iter == mResourceLocks.end() )
                    {
                        iter = mResourceLocks.insert( make_pair(res, AllocateResouceLock()) );
                    }
                    ResourceLock* resLock = iter->second;
                    resLock->lockedCommandLocks.push_front( cmd->CommandLock() );
                }
                
                mCommands.push_front(cmd);
                mNewCommand = true;
            }
            
            mConsumerCond.notify_one();
        }
        
        void ExecuteCommands()
        {
            while(true)
            {
                {
                    lock mutexLocker(mMutex);
                    mConsumerCond.wait(mMutex, [this](){ return this->Executable(); });
                    
                    if (mNewCommand)
                    {
                        UnlockCommandResources(nullptr);
                        mNewCommand = false;
                    }
                    
                    while(true)
                    {
                        Command* cmd = mCommands.back();
                        if( !Executable(cmd) ) break;
                        AsyncExecute(cmd);
                        mCommands.pop_back();
                    }
                }
                
                mProducerCond.notify_one();
            }
        }
    
        void ReleaseResource(Resource* res)
        {
            lock mutexLocker(mMutex);
            
            auto iter = mResourceLocks.find(res);
            if (iter != mResourceLocks.end() )
            {
                FreeResourceLock(iter->second);
                mResourceLocks.erase(iter);
            }
        }
        
    private:
        vector<resourcelock*>                   mResourceLockPool;
        unordered_map<resource*, resourcelock*> mResourceLocks;
        deque<command*>                         mCommands;
        bool                                    mNewCommand;
        
        ResourceLock* AllocateResourceLock()
        {
            if( mResourceLockPool.empty() )
            {
                mResourceLockPool.push_back( new ResourceLock() );
            }
            ResourceLock* ret = mResourceLockPool.back();
            mResourceLockPool.pop_back();
            return ret;
        }
        
        void FreeResourceLock(ResourceLock* resLock)
        {
            mResourceLockPool.push_back(resLock);
        }
        
        bool Executable()
        {
            if ( mCommands.empty() )
            {
                return false;
            }
            
            if( Executable(mCommands.back()) )
            {
                return true;
            }
            
            return false;
        }
        
        bool Executable(Command* cmd)
        {
            return cmd->ResourceCommandLock().lockedResourcesCount == 0;
        }
        
        void AsyncExecute(Command* cmd)
        {
            async( [this](){ cmd->Execute(); this->UnlockCommand(cmd);} );
        }
        
        template 
        void UnlockResource(IteratorT const& iter)
        {
            ResourceLock* resLock = iter->second;
            
            bool isUnlockingReaders = false;
            if( resLock->lockingCount > 0)
            {
                if( resLock->lockingAccess == ResourceAccessType::Read )
                {
                    isUnlockingReaders = true;
                }
                else
                {
                    return;
                }
            }
            
            while(!resLock->lockedCommandLocks.empty())
            {
                CommandLock* cmdLock = resLock->lockedCommandLocks.back();
                
                if (isUnlockingReaders && cmdLock->access != ResourceAccessType::Read)
                {
                    break;
                }
                
                --cmdLock->lockedResourcesCount;
                ++resLock->lockingCount;
                lockedCommandLocks->pop_back();
                
                if(cmdLock->access == ResourceAccessType::Read)
                {
                    isUnlockingReaders = true;
                }
                else
                {
                    break;
                }
            }
        }
        
        void UnlockCommandResources(Commmand* cmd)
        {
            if( cmd == nullptr )
            {
                for(auto iter = mResourceLocks.begin(); iter != mResourceLocks.end(); ++iter)
                {
                    UnlockResource(iter);
                }
            }
            else
            {
                for(auto res: cmd->Resources())
                {
                    auto iter = mResourceLocks.find(res);
                    --(*iter)->lockingCount;
                    UnlockResource(iter);
                }
            }
        }
        
        void UnlockCommand(command* cmd)
        {
            {
                lock mutexLocker(mMutex);
                UnlockCommandResources(cmd);
            }
            
            mConsumerCond.notify_one();
        }
    
    };

    在实际的硬件和驱动中,Producer和Consumer自身可能都是串行的;那么此时只需对Producer所使用的资源做读写计数即可(这个引用计数相当于是一个Critical Section,只是为了让Consumer和Producer进行同步,Consumer和Producer内部都是串行的,所以也一定是顺序一致的。具体的理论可以参见《多核处理器编程的艺术》。):

    • 如果是GPU执行的命令,在进入GPU Queue时,增加命令所使用的资源读或写的引用计数;当GPU的命令执行完后,驱动会收到信息,减少引用计数。
    • 如果是CPU端的Map/Unmap,直接检查GPU资源引用计数,如果资源仍然被GPU占用,那么就阻塞或返回;如果没有GPU占用,那就正常的映射到内存中。

    当然,我还试图做过一个更加简单的版本,那就是,CPU一旦需要锁定资源,那干脆就阻塞到所有的Producer命令结束再执行。这个实现手段更加简单,只不过不该等的也等了,效果上自然也要更差一些。

    通过这些手段,可以大大减少CPU要等待GPU执行完才能继续执行的情况。当然,如果在GPU工作时仍然要读写GPU上的资源会导致访问冲突,由此带来的阻塞也是不可避免的。此时就需要应用程序视情况进行优化,或者通过NO_OVERWRITE或DISCARD明确的告诉驱动,用户代码对于资源的读写与正在执行的操作不冲突。

    跨线程对象的生命期管理

    在没有GC的情况下,线程安全的引用计数/智能指针几乎是最好、也是唯一的跨线程对象生命期管理手段。如果你的智能指针与std中的shared_ptr一样,这里也没有特殊强调的地方。
    但是如果是类似于COM对象,是一个有着内嵌引用计数的裸指针这样的呢?要如何避免以下的代码出现致命的错误?

    ID3D11Buffer* buffer = dev->CreateBuffer( ... ); 
    buffer->Release(); 
    devContext->IASetIndexBuffer(buffer);
    // ...
    devContext->Draw(...);

    我们知道,COM对象在Create之后就Release,COM的引用计数就会归零,对象也会被析构。此时的buffer就相当于是一个悬挂指针。对它的一切操作几乎都会导致不可预料的后果。
    指针本身也没有任何办法说明自己的有效性。那么D3D Runtime如何检查这样的悬挂指针呢?

    我们注意到,Buffer是从Device中创建出来的。一个比较容易考虑到的方案是:
    在Device中保留有所有创建出来的Buffer,并且Buffer也有一个Device指针,Buffer在释放的时候也会通知Device,Device将指针在表中移除。

    在通过API设置的时候,可以通过Device检查这个Buffer是否存活。

    当然,这事儿你可以做的更极端,例如

    memset(buffer, 0, YouKnowTheSizeOfBuffer); 
    devContext->IASetIndexBuffer(buffer);

    那通过这种方式是检查不了的。甚至即便在对象字段中增加Guard加以检查和保护,也没有办法避免对对象数据进行针对性的破坏。

    不过好在这些问题只可能在User Mode Driver(UMD)中发生。如果出现异常,大不了程序Crash就好了。真正和设备、和操作系统内核服务打交道的,是Kernel Mode Driver(KMD)。UMD到KMD是严格隔离的,KM中的程序有自己的地址空间,彼此之间无法直接访问内存,数据的传递必须进行拷贝。这些隔离措施,都是我们常说的用户态到内核态切换成本的一部分。

    异步调用的错误返回机制

    和同步调用相比,异步调用对于错误处理是不那么友好的。用户发起的调用还在执行、甚至还没开始执行,函数就已经返回了,所以你根本就不知道发起的异步调用出现了什么错误;错误发生了、异步调用中断了,又不知道怎么传递给调用方;调用方拿到错误了,又不一定知道哪里发生的。

    异步调用的错误返回机制就是为了解决这三个问题,虽然未必能解决的了。

    在讨论异步调用的错误和异常处理方法之前,先要看看必要性。

    1.如果错误不需要被处理,而且执行过程有容错机制,那么只要将命令甩出去执行就好了,不需要关心有什么错误、是怎么处理的。例如显卡上一些Shader值的错误会导致目标渲染成警告色(例如红色),但是硬件本身不会崩溃,也不会给用户返回任何的错误信息;
    2.如果调用方不需要知道究竟发生了什么错误,只要这个错误被处理就行了,而且它知道怎么样处理错误,那可以使用回调函数来处理错误,或者是CPS的调用风格;
    3.调用方需要知道发生了什么错误。这种情况需要有隐式或显式的同步点,在这个同步点上,调用方会等待被异步调用的函数给它返回一个信号。这个信号要么是结果,要么是一个错误或异常。C++11引入的std::future就可以解决这一个问题。下面这段伪代码大致解释了它的实现原理。

    void thread_func() 
    { 
        // work, work. 
    }
    
    // 这个 wrapper 的作用就是捕获线程函数的错误,防止错误被传播到线程外。 
    void thread_func_wrapper(thread_result& result) 
    { 
        try 
        { 
            thread_func(); 
        } 
        catch( exception& e ) 
        { 
            // result是一个条件变量,设置了异常或者值后,被这个条件变量阻塞的线程会继续执行。 
            result.set_exception(e); 
            return; 
        } 
        
        result.set_value(e); 
    }
    
    
    void thread_caller() 
    { 
        // 异步调用。注意,调用的是那个能捕获错误的函数 
        thread_result result; 
        async( bind(thread_func_wrapper, result) ); 
        
        // ... 干点儿别的 ... 
        
        try 
        { 
            // 等这个条件变量。
            // 如果线程调用了set_value,那阻塞结束后就返回结果;否则就把这个异常重新抛出来。 
            result_value = result.get_result();    
        } 
        catch( exception& e ) 
        { 
            // 现在你知道是什么错误了,处理它吧。 
        } 
    }

    如果异常中有堆栈信息,或者线程异常一触发就被调试器捕获,那你自然就知道异常出现在什么地方了。当然这个例子中,异常不是必须的,你也可以用返回值来表示异步调用的函数是否正确。

    但是对于D3D10来说,这个问题要更复杂一些。因为异步调用之后,没有显式的同步点。比如没有API能让你写下面这一段代码:

    devContext->Draw( ... );
    // ... 干点别的 ...
    devContext->IsLastFuckingDrawFuckingSucceed();

    虽然有一些同步点,例如Present(D3D 11.2 以后,这里也没得同步了)。但是你总不能把Draw的错误放在Present上吧,而且你还不知道是哪个Draw的。

    所以D3D采用了一个折中的方案:

    1. 如果一个函数执行时有错能立刻检查出来,那就通过返回值返回。
    2. 如果检查不出来,那就容错。

    所以D3D的API在调用的时候都有尽可能多的检查;特别是在Draw之前,会检查各个渲染状态之间互不冲突。如果检查出有任何问题,例如无法分配Buffer等,就会通过HRESULT返回给调用方。一旦检查结束,将Draw调用转化成GPU执行的指令,那再出任何问题,就只能期待KMD和硬件的容错机制了。

    后记

    尽管此文酝酿时间不短,从整理需求、阅读API Remark、设计异步解决方案开始算起已经有月余,又有三四个版本原型的SALVIA的工程实践,文章也写了好几天,但是还是觉得叙述零碎,不够完整,有诸多不满意之处。所以此文可能仍然会更新一段时间以修正一些错误、补充一些材料。也恳请各位提出宝贵意见,助我修缮全文。在此先谢过。

  • 相关阅读:
    博弈论嘻嘻
    cf之kmp匹配稍稍改一改
    点分治开始!
    后缀数组
    cf之 前缀和差分
    AJAX 数据库实例
    常用jar包用途
    cxf客户端所需最少jar包
    Dbutis
    dbutils入门
  • 原文地址:https://www.cnblogs.com/lingjingqiu/p/3170727.html
Copyright © 2020-2023  润新知