系列文章目录

一、前言

本章讨论 OpenGL ES 中多线程技术的运用。首先,说明我们为什么需要这项技术,在 ARM OpenGL ES 教程 thread_sync 中提到

当我们转向更复杂的图形应用程序时,可能会想要使用多线程(MT)。一个典型的情况是,我们的图形应用程序需要执行大量的数学运算。在这种情况下,可能通过将工作量移至不同于管理图形操作的线程来提高性能。另一个常见的例子是我们想让图形用户界面(GUI)在一个独立的线程中运行。

多线程的好处非常重要。MT使得我们的应用程序始终保持响应,并且不只是与应用程序的GUI有关。任何设备都在后台持续执行着大量的额外任务,尤其是与网络相关的服务。因此,在次要线程中执行重型任务变得至关重要。

如今,更多的嵌入式设备配备了多处理器架构。如果我们真的想要充分利用这些处理器,那么我们肯定需要MT。只有当处理器需要管理多个并发线程时,像超线程和多核处理器这样的技术才能被有效地利用。

多线程编程模型隐含了同步的需要。每个多线程应用程序都需要以协调的方式管理其不同的线程。线程之间的同步可以通过MT库中可用的标准互斥体、信号量和条件变量来实现。

本文提到的示例代码你可以在 ThreadSyncDrawer 中找到

二、从 OpenGL 的 C/S 架构说起

OpenGL是一种规范或接口,它主要用于管理应用程序与图形硬件之间的交互。不论渲染任务的复杂性如何,调用OpenGL API的时间基本上是很短的。这是因为当你调用OpenGL API时,你实际上只是向发送了一条命令,图像渲染的实际工作是在GPU上完成的。

我们将这种与GPU交互的方式称之为客户端/服务器(C/S)架构。在这里,调用OpenGL API的CPU作为客户端,而执行渲染任务的GPU则作为服务器。

在OpenGL的上下文(GL Context)中,有一个命令队列。每当你调用OpenGL API时,你就在向这个队列发送一个命令。每个线程都可以绑定到一个OpenGL上下文,这意味着不同的线程具有不同的命令队列。

在GPU中也有一个命令队列,但是它只有一个。在硬件驱动的协调下,来自不同线程的命令将被逐一送入GPU的命令队列中。GPU将按照命令的顺序来执行这些命令。

在这里插入图片描述
上图中,有三个线程,每个线程执行了一些 OpenGL API,往在 GL Context 的命令队列上发送了命令,我在图中对每个命令做了编号。现在提问:按照上图所示,GPU 上的执行顺序可能是?

在多线程环境中,由于线程调度的不确定性,命令的执行顺序可能有多种。不过,每个线程的命令在各自的GL context中的执行顺序是固定的,即1.1必须在1.2前执行,1.2必须在1.3前执行,同理,2.1在2.2前,2.2在2.3前,3.1在3.2前,3.2在3.3前。

假设这三个线程的命令进入 GPU 的命令队列,那么可能的执行顺序就非常多。以下是一些可能的顺序:

  • 1.1、1.2、1.3、2.1、2.2、2.3、3.1、3.2、3.3
  • 1.1、2.1、3.1、1.2、2.2、3.2、1.3、2.3、3.3
  • 2.1、2.2、2.3、1.1、1.2、1.3、3.1、3.2、3.3
  • 2.1、1.1、3.1、2.2、1.2、3.2、2.3、1.3、3.3
  • 3.1、2.1、1.1、3.2、2.2、1.2、3.3、2.3、1.3
  • 等等

可以看到虽然在 CPU 上各个线程执行时间有先有后,但由于线程调度和硬件驱动调用的不确定性,在 GPU 上命令执行的顺序实际上有多种可能。

三、OpenGL 的同步接口

3.1 glFlush 和 glFinish

在 OpenGL ES 2.0 版本中,无法做到跨线程之间的同步,我们能够使用的工具很少,只有 glFlush 和 glFinish。

glFlush和glFinish都用于控制OpenGL的执行。两者的区别在于他们控制执行流程的方式和时机。

glFlush()是一种用于迫使之前的OpenGL命令开始执行的方法。当你在程序中调用glFlush(),OpenGL会把所有待执行的命令放入图形处理器(GPU)的命令队列中。这意味着,无论你的程序在何时何地调用glFlush(),你都可以确保所有已经发送到GPU的命令都将最终被执行。然而,调用glFlush()并不意味着等待所有命令完成执行。你的程序会在glFlush()命令返回后继续运行,同时GPU也会开始处理命令队列中的命令。

另一方面,glFinish()不仅会迅速开始执行所有尚未开始执行的OpenGL命令,还会一直阻塞,直到所有的命令都完成执行,并且所有的结果都已返回到你的程序。当glFinish()返回时,你可以确定所有之前的OpenGL命令都已经完成了执行。由于glFinish()会等待所有OpenGL命令完成执行,所以通常会因为大量的等待时间而导致程序性能下降。除非必要,否则通常不推荐频繁使用glFinish()。

总的来说,glFlush()和glFinish()都是OpenGL的同步命令,可以用来控制OpenGL命令的执行流程。然而,glFlush()是非阻塞的,只要求命令尽快开始执行,而glFinish()则是阻塞的,需要等待所有命令完成。

3.2 同步对象

3.2.1 fence

在 OpenGL ES 3.0 中引入了同步对象,这使得 OpenGL ES 的多线程编程更具有控制性。你可以使用下面的 API 来创建一个同步对象

GLsync glFenceSync(GLenum condition, GLbitfield flags);

其中 condition 必须是 GL_SYNC_GPU_COMMANDS_COMPLETE,而 flags 填 0 即可。

同步对象有两种可能的状态:已触发(signaled)和未触发(unsignaled)。当使用glFenceSync创建一个同步对象时,它处于未触发状态。它还会在OpenGL命令流中插入一个栅栏(fence)命令,并将其与同步对象关联起来。当glFenceSync命令的条件得到满足时,即命令流中直到栅栏位置的所有命令都执行完毕时,状态会转变为已触发。

3.2.2 glClientWaitSync 和 glWaitSync

OpenGL 提供了两个函数来等待同步对象触发,分别是

GLenum glClientWaitSync(GLsync sync, GLbitfield flags, GLuint64 timeout);
void glWaitSync(GLsync sync, GLbitfield flags, GLuint64 timeout);

函数glClientWaitSync会阻塞所有CPU操作,直到同步对象被发出信号。如果同步对象在超时时间内没有被发出信号,函数会返回一个状态码来表示这一点。

而对于glWaitSync,行为稍有不同。图形命令在GPU上是严格按照顺序执行的,所以当命令流中达到一个同步对象时,可以保证所有之前的命令都已经完成。
应用程序实际上不会等待同步对象被发出信号;只有GPU会等待。因此,glWaitSync将立即返回到应用程序。由于应用程序不等待函数返回,所以不存在挂起的危险,因此必须将标志值设为零。此外,超时实际上将取决于实现,因此指定特殊的超时值GL_TIMEOUT_IGNORED以明确这一点。

3.2.2 举个例子

为了说明 glWaitSyncglClientWaitSync 机制原理和区别,我们来看一个具体的实例。

设想有两个线程T0和T1,其中T0线程负责将图像绘制到纹理上,而T1线程需要使用T0线程准备好的纹理进行特效处理。也就是说,T1需要等待T0完成绘制。为了让T1等待T0,我们在T0中创建一个同步对象,并同时向T0的命令队列发送一个栅栏(fence)命令。在T1上,我们可以使用glWaitSync命令来让GPU等待,以确保正确的执行顺序:

  • T0:1.1、1.2、1.3(fence)
  • T1:2.1(glWaitSync)、2.2、2.3

在多线程环境中,由于线程调度的不确定性,T0和T1在GPU上的执行顺序可能有多种。假设当前GPU上的命令队列是这样的:

  • GPU:2.1(glWaitSync)、2.2、2.3、1.1、1.2、1.3(fence)

当GPU执行了2.1(glWaitSync)后,它发现这是一个glWaitSync命令,于是停止执行之后的命令。这里的“之后的命令”是指在同一OpenGL上下文中的命令。也就是说,GPU并不会完全停下来,它仍会执行其他上下文的命令。因此,GPU继续执行1.1、1.2、1.3(fence)。当fence命令执行完毕后,同步对象被触发,于是GPU可以继续执行T1上的其他命令,也就是2.2和2.3。总的来说,在这种情况下,GPU的执行顺序为:2.1(glWaitSync)、1.1、1.2、1.3(fence)、2.2、2.3。

在T0和T1线程中,如果使用glClientWaitSync,其行为将有所不同。T0和T1的命令队列如下:

  • T0:1.1、1.2、1.3(fence)

  • T1:2.1(glClientWaitSync)、2.2、2.3

首先,因为T1需要等待T0的同步对象,所以T1中的2.1(glClientWaitSync)命令在CPU上的执行时间肯定会晚于T0中的1.3(fence)。而且,因为glClientWaitSync会在CPU上阻塞,所以2.2和2.3在CPU上不会立即执行,也就不会立即发送给GPU。因此,当前GPU上的命令队列可能如下:

  • GPU:2.1(glClientWaitSync)、1.1、1.2、1.3(fence)

GPU会按照命令队列的顺序执行。当执行完成1.3(fence)命令后,CPU会收到信号,解除阻塞,接着执行2.2和2.3。因此,在这个情况下,GPU的执行顺序可能为:2.1(glClientWaitSync)、1.1、1.2、1.3(fence)、2.2、2.3。

额外要说的是,一个同步对象可以被多个 glWaitSync 调用等待。当与同步对象关联的fence命令被执行(即纹理绘制等操作完成),同步对象会变为已发信号状态。在这种状态下,所有等待这个同步对象的 glWaitSync 调用都将收到通知,并允许之后的命令开始执行。

换句话说,一旦同步对象收到信号,所有等待它的 glWaitSync 调用都将返回,不仅仅是其中一个。

因此,即使有更复杂的情况,例如有三个线程分别是 T0 、T1 和 T2,其中 T0 负责绘制纹理,T1 和 T2 需要等待纹理绘制完毕后进行其他的处理,这种情况下,我们仍然能够使用 glWaitSyncglClientWaitSync 来实现同步。

四、多线程中的上下文管理

在 OpenGL 多线程编程中会涉及到如何管理 OpenGL Context 的问题,有两种方式来进行多线程之间共享上下文。

第一种方式,只持有一个 OpenGL Context,在多个线程中切换上下文,使其当前线程绑定那唯一的上下文。操作顺序如下:

  • 将上下文设为线程1的当前上下文:eglMakeCurrent(display, surface, surface, context);
  • 在线程1中执行 OpenGL ES 操作…
  • 将上下文从线程1解绑:eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
  • 将上下文设为线程2的当前上下文:eglMakeCurrent(display, surface, surface, context);
  • 在线程2中执行 OpenGL ES 操作…

在第二种方式中,每个线程都有其自己的渲染上下文,并且因为上下文是相互独立的,它们可以同时是当前上下文。为每个线程创建单独的渲染上下文具有显著优势。首先,我们无需担心从一个线程解绑再绑定到另一个线程的唯一渲染上下文,这常常是导致应用程序挂起的常见原因。其次,更重要的是,我们不会影响性能,因为命令队列可以保持持续供应。每次从一个线程解绑渲染上下文时,该线程将无法执行任何图形操作。这种上下文切换会降低应用程序的性能,特别是当这些切换频繁发生时。

需要注意的是,我们需要指定一个共享上下文(shared context),然后基于这个共享上下文创建其他所有上下文,这样我们就能在不同的上下文之间共享资源。

五、Show the code

接下来看一个实际的例子,该例子来自于 ARM 的 OpenGL ES 教程 ThreadSync.java,这份代码主要逻辑使用的是 C/C++ 实现,基于它我重写成 Kotlin 以便各位看官理解。所有代码可以在 ThreadSyncDrawer

在这份代码中,你可以看到 fence 命令和 glWaitSync 之间是如何做线程同步的,以及如何管理多线程中的 OpenGL Context,希望对你有所帮助。

这份代码中,有两个线程:

  1. 主线程,主要负责将纹理绘制到屏幕上。
  2. 子线程,负责更新纹理上的数据。

如果两个线程之间不做同步,那么你会看到偶尔有画面失真的情况出现,如下图:

我们看具体代码,首先是主线程:

override fun prepare(context: Context) {
        // .... 与之前代码一致

        // set texture image data
        animateTexture()
        val byteBuffer: ByteBuffer = ByteBuffer.wrap(textureData)
        GLES30.glTexImage2D(GLES30.GL_TEXTURE_2D, 0, GLES30.GL_RGBA, texWidth, texHeight, 0,
            GLES30.GL_RGBA, GLES30.GL_UNSIGNED_BYTE, byteBuffer)
        MyGLUtils.checkGlError("texImage2D")

        // unbind texture
        GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, 0)

        if(useFence.get()){
            mainThreadSyncObject = GLES30.glFenceSync(GLES30.GL_SYNC_GPU_COMMANDS_COMPLETE, 0)
            Log.d(TAG,"Use of GL Fence enabled.")
        }

        createWorkingThread()
    }

主线程的 prepare 函数中,主要是做一些准备工作,这些代码基本上在前面几章中都有详细的解释了,不再赘述,让我们重点与之前代码有区别的地方:

  1. animateTexture(): 是一个自定义方法,用于在纹理上应用动画效果。这个方法参考了 ThreadSync.cpp 中的实现

  2. byteBuffer: 创建一个ByteBuffer对象,将纹理数据存储在其中。

  3. GLES30.glTexImage2D(): 这个函数会将纹理数据传递给OpenGL ES,用于创建2D纹理。参数分别是纹理目标,纹理的mipmap层级,纹理的内部格式,纹理的宽度和高度,边缘的宽度,源数据格式,源数据类型和存储纹理数据的ByteBuffer。

  4. if(useFence.get()): 这里根据useFence标志位的状态来确定是否启用GL Fence。如果启用,则调用GLES30.glFenceSync()创建一个用于同步OpenGL指令的GL Fence对象。

  5. createWorkingThread(): 用于创建工作线程的方法。

接下来看下 draw 方法中的逻辑:

override fun draw() {
        if(useFence.get())
        {
            if(workingThreadSyncObject != 0L)
            {
                GLES30.glWaitSync(workingThreadSyncObject, 0, GLES30.GL_TIMEOUT_IGNORED)
            }
            else
            {
                return
            }
        }

        shader.use()
        shader.setInt("texture0", 0)

        GLES30.glViewport(0, 0, screenWidth, screenHeight)

        GLES30.glClear(GLES30.GL_COLOR_BUFFER_BIT)

        GLES30.glBindVertexArray(vaos[0])
        GLES30.glActiveTexture(GLES30.GL_TEXTURE0)
        GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, texIds[0])

        GLES30.glDrawElements(GLES30.GL_TRIANGLES, indices.size, GLES30.GL_UNSIGNED_INT, 0)

        // unbind vao
        GLES30.glBindVertexArray(0)

        if(useFence.get())
        {
            if(mainThreadSyncObject == 0L)
            {
                Log.i(TAG, "mainThreadSynobj == NULL at the end of renderframe.")
            }

            mainThreadSyncObject = GLES30.glFenceSync(GLES30.GL_SYNC_GPU_COMMANDS_COMPLETE, 0)
        }
    }
  1. 该函数主要在执行绘制(draw)功能。绘制过程可能涉及线程同步和防止数据竞争,需要使用OpenGL的fence来进行同步等待。具体看代码中if(useFence.get())的部分。

  2. 使用GLES30.glWaitSync函数会让当前(在这里就是GL)线程等待一个fence sync object。如果workingThreadSyncObject不为0(即fence sync object存在),GL线程会进入等待状态,直到此fence被触发。

  3. 如果workingThreadSyncObject为0,那么函数直接返回,不执行后面的绘制过程。

  4. 使用shader,设置其属性"texture0"为0。

  5. GLES30.glViewport将视图设置成占满整个屏幕。

  6. GLES30.glClear用来清除屏幕,使其为单色。

  7. GLES30.glBindVertexArray和GLES30.glBindTexture是准备绘制素材,即准备好VAO和纹理。

  8. GLES30.glDrawElements进行实际的绘制。其中的参数GL_TRIANGLES表示用三角形进行绘制,indices.size表示索引数目,GL_UNSIGNED_INT表示索引是无符号整形。

  9. 绘制完成后,使用GLES30.glBindVertexArray(0)来解绑VAO。

  10. 最后使用GLES30.glFenceSync创建一个新的fence sync object,并将其赋值给mainThreadSyncObject,用于之后的线程同步。如果mainThreadSyncObject之前为0(旧的fence不存在),则打印一条信息。

接下来,我们来看子线程的代码:

private fun createWorkingThread(){
        // get shared context
        val sharedEGLContext = EGL14.eglGetCurrentContext()

        val workingThread = Thread{
            // shared egl context
            val eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
            val version = IntArray(2)
            if (!EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) {
                throw RuntimeException("unable to initialize EGL14")
            }

            val attribList = intArrayOf(
                EGL14.EGL_RED_SIZE, 8,
                EGL14.EGL_GREEN_SIZE, 8,
                EGL14.EGL_BLUE_SIZE, 8,
                EGL14.EGL_ALPHA_SIZE, 8,
                EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
                EGL14.EGL_NONE
            )

            val configs = arrayOfNulls<EGLConfig>(1)
            val numConfigs = IntArray(1)
            if (!EGL14.eglChooseConfig(
                    eglDisplay, attribList, 0,
                    configs, 0, configs.size, numConfigs, 0
                )
            ) {
                throw RuntimeException("unable to find RGB888+recordable ES2 EGL config")
            }
            val attribListContext = intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE)
            val eglContext = EGL14.eglCreateContext(eglDisplay, configs[0], sharedEGLContext, attribListContext, 0)

            if (eglContext == null || eglContext == EGL14.EGL_NO_CONTEXT) {
                throw RuntimeException("Failed to create new context.")
            }

            // Create a new surface to make current
            val attribListSurface = intArrayOf(EGL14.EGL_WIDTH, 1, EGL14.EGL_HEIGHT, 1, EGL14.EGL_NONE)
            val eglSurface = EGL14.eglCreatePbufferSurface(eglDisplay, configs[0], attribListSurface, 0)

            // eglMakeCurrent
            if (!EGL14.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext)) {
                throw RuntimeException("eglMakeCurrent failed")
            }

            // loop for updating text data

            while(!threadExit.get())
            {
                Thread.sleep(1000 / 60)

                animateTexture()

                if(useFence.get())
                {
                    if(mainThreadSyncObject != 0L)
                    {
                        GLES30.glWaitSync(mainThreadSyncObject, 0, GLES30.GL_TIMEOUT_IGNORED)
                    }else
                    {
                        continue
                    }

                    GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, texIds[0])
                    val byteBuffer: ByteBuffer = ByteBuffer.wrap(textureData)
                    GLES30.glTexImage2D(GLES30.GL_TEXTURE_2D, 0, GLES30.GL_RGBA, texWidth, texHeight, 0,
                        GLES30.GL_RGBA, GLES30.GL_UNSIGNED_BYTE, byteBuffer)

                    workingThreadSyncObject = GLES30.glFenceSync(GLES30.GL_SYNC_GPU_COMMANDS_COMPLETE, 0)
                }else
                {
                    GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, texIds[0])
                    val byteBuffer: ByteBuffer = ByteBuffer.wrap(textureData)
                    GLES30.glTexImage2D(GLES30.GL_TEXTURE_2D, 0, GLES30.GL_RGBA, texWidth, texHeight, 0,
                        GLES30.GL_RGBA, GLES30.GL_UNSIGNED_BYTE, byteBuffer)
                }


            }

        }
        workingThread.start()
    }
  1. 这个函数首先被命名为“createWorkingThread”,暗示它是在创建一个工作线程。线程创建过程中会用到共享的EGL context参数。

  2. 接着创建一个新线程。在该线程中,首先获取默认的EGLDisplay,并初始化它。

  3. 设置一个int型数组”attribList”作为参数,用来为配置创建EGLContext。

  4. EGL14.eglChooseConfig函数则是用来选择一个符合这些条件的配置并返回。

  5. 在线程中获取新的EGLcontext,如果没有成功创建的情况下,会报出运行时异常。

  6. 然后创建一个对应的PbufferSurface,这是EGL中的一个off-screen surface。

  7. 使用EGL14.eglMakeCurrent把刚刚创建的context和surface关联起来。

  8. 创建一个循环,在这个循环里,每隔一定时间(16ms,相当于60帧的间隔)就调用“animateTexture()”更新纹理。

  9. 在使用fence的情况下,如果主线程的sync object存在,则等待其触发。然后更新texture,并创建一个新的工作线程的sync object以供下次循环使用。

  10. 如果不使用fence,那么直接更新texture。

  11. 循环结束,线程工作完成。此后,新创建的线程会自行运行,更新纹理数据。

参考

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐