Skip to content

defer

الكلمة المفتاحية defer ذات تكرار عالٍ جدًا في التطوير اليومي لـ Go، وتُنفَّذ بطريقة الداخل أولًا الخارج أخيرًا للدوال المرتبطة بـ defer، وغالبًا ما نستخدم هذه الآلية لإجراء عمليات تحرير الموارد، مثل إغلاق الملفات وغيرها.

go
fd, err := os.Open("/dev/stdin")
if err != nil{
    return err
}
defer fd.Close()
...

كونها كلمة مفتاحية ذات تكرار عالٍ، يجعل من الضروري فهم بنيتها الأساسية.

البنية

الكلمة المفتاحية defer تقابلها البنية runtime._defer، وبنيتها ليست معقدة:

go
type _defer struct {
  started bool
  heap    bool
  openDefer bool
  sp        uintptr // sp at time of defer
  pc        uintptr // pc at time of defer
  fn        func()  // can be nil for open-coded defers
  _panic    *_panic // panic that is running defer
  link      *_defer // next defer on G; can point to either heap or stack!
  fd   unsafe.Pointer // funcdata for the function associated with the frame
  varp uintptr        // value of varp for the stack frame
  framepc uintptr
}

الحقل fn هو الدالة المقابلة للكلمة المفتاحية defer، و link يشير لـ defer التالي في السلسلة، و sp و pc يسجلان معلومات الدالة المستدعية، ويُستخدمان للحكم على الدالة التي ينتمي إليها defer. يوجد defer في وقت التشغيل كقائمة مرتبطة، ورأس القائمة موجود على الكوروتين G، لذا فإن defer في الواقع مرتبط مباشرة بالكوروتين.

go
type g struct {
    ...
  _panic    *_panic // innermost panic - offset known to liblink
  _defer    *_defer // innermost defer
    ...
}

عندما ينفذ الكوروتين دالة، يُضيف defer الخاص بالدالة من رأس القائمة المرتبطة بالترتيب:

go
defer fn1()
defer fn2()
defer fn3()

الكود أعلاه يوافق هذه الصورة:

بخلاف الكوروتين، P أيضًا له علاقة مع defer، ففي بنية P يوجد حقل deferpool، كما يلي:

go
type p struct {
  ...
  deferpool    []*_defer // pool of available defer structs (see panic.go)
  deferpoolbuf [32]*_defer
    ...
}

يحتوي deferpool على هياكل defer مُخصصة مسبقًا، لتُستخدم لتخصيص هياكل defer جديدة للكوروتين G المرتبط بـ P، مما يقلل التكلفة.

التخصيص

من الناحية النحوية، استخدام الكلمة المفتاحية defer سيُحوِّله المترجم لاستدعاء الدالة runtime.deferproc. مثلًا، كود Go يُكتب هكذا:

go
defer fn1(x, y)

وبعد الترجمة يكون الكود الفعلي هكذا:

go
deferproc(func(){
  fn1(x, y)
})

لذا فإن الدالة المُمررة لـ defer فعليًا ليس لها معاملات ولا قيمة إرجاع، وكود الدالة deferproc كالتالي:

go
func deferproc(fn func()) {
  gp := getg()
  d := newdefer()
  d.link = gp._defer
  gp._defer = d
  d.fn = fn
  d.pc = getcallerpc()
  d.sp = getcallersp()
  return0()
}

هذه الدالة مسؤولة عن إنشاء بنية defer وإضافتها لرأس القائمة المرتبطة للكوروتين G، والدالة runtime.newdefer ستحاول الحصول على بنية defer مُخصصة مسبقًا من deferpool في P.

go
if len(pp.deferpool) == 0 && sched.deferpool != nil {
    lock(&sched.deferlock)
    for len(pp.deferpool) < cap(pp.deferpool)/2 && sched.deferpool != nil {
        d := sched.deferpool
        sched.deferpool = d.link
        d.link = nil
        pp.deferpool = append(pp.deferpool, d)
    }
    unlock(&sched.deferlock)
}

أولًا سيأخذ نصف هياكل defer من sched.deferpool العام ويملأ بها deferpool المحلي، ثم سيحاول الحصول على من deferpool في P:

go
if n := len(pp.deferpool); n > 0 {
    d = pp.deferpool[n-1]
    pp.deferpool[n-1] = nil
    pp.deferpool = pp.deferpool[:n-1]
}

if d == nil {
    // Allocate new defer.
    d = new(_defer)
}
d.heap = true

أخيرًا إذا لم يجد سيستخدم التخصيص اليدوي. ويمكن رؤية هذا الكود:

go
d.heap = true

هذا يعني أن defer مُخصَّص على الكومة، وبالمقابل عندما يكون false، سيُخصَّص على المكدس، والذاكرة المُخصصة على المكدس تُسترد تلقائيًا عند الإرجاع، وكفاءة إدارتها للذاكرة أعلى من الكومة، والعامل الذي يحدد ما إذا كان سيُخصَّص على المكدس هو عدد طبقات الحلقة، ويمكن تتبع هذا المنطق للدالة escape.goDeferStmt في cmd/compile/ssagen:

go
func (e *escape) goDeferStmt(n *ir.GoDeferStmt) {
  ...
  if n.Op() == ir.ODEFER && e.loopDepth == 1 {
    k = e.later(e.discardHole())
    n.SetEsc(ir.EscNever)
  }
    ...
}

e.loopDepth يمثل عدد طبقات الحلقة للعبارة الحالية، إذا لم تكن عبارة defer الحالية داخل حلقة، فسيتم تخصيصها على المكدس.

go
case ir.ODEFER:
    n := n.(*ir.GoDeferStmt)
    if s.hasOpenDefers {
      s.openDeferRecord(n.Call.(*ir.CallExpr))
    } else {
      d := callDefer
      if n.Esc() == ir.EscNever {
        d = callDeferStack
      }
      s.callResult(n.Call.(*ir.CallExpr), d)
    }

إذا كان التخصيص على المكدس، فستُنشأ بنية defer مباشرة على المكدس، وأخيرًا ستُكمِل الدالة runtime.deferprocStack إنشاء بنية defer.

go
if k == callDeferStack {
    // Make a defer struct d on the stack.
    if stksize != 0 {
      s.Fatalf("deferprocStack with non-zero stack size %d: %v", stksize, n)
    }

    t := deferstruct()
    ...
    // Call runtime.deferprocStack with pointer to _defer record.
    ACArgs = append(ACArgs, types.Types[types.TUINTPTR])
    aux := ssa.StaticAuxCall(ir.Syms.DeferprocStack, s.f.ABIDefault.ABIAnalyzeTypes(nil, ACArgs, ACResults))
    callArgs = append(callArgs, addr, s.mem())
    call = s.newValue0A(ssa.OpStaticLECall, aux.LateExpansionResultType(), aux)
    call.AddArgs(callArgs...)
    call.AuxInt = int64(types.PtrSize) // deferprocStack takes a *_defer arg

توقيع الدالة deferprocStack:

go
func deferprocStack(d *_defer)

منطق الإنشاء المحدد ليس مختلفًا كثيرًا عن deferproc، والفرق الرئيسي هو أن مصدر بنية defer عند التخصيص على المكدس هو بنية مُنشأة مباشرة، بينما مصدر defer المُخصص على الكومة هو الدالة new.

التنفيذ

عندما تكون الدالة على وشك الإرجاع أو عند حدوث panic، سيُدخَل في الدالة runtime.deferreturn، وهي مسؤولة عن إخراج defer من القائمة المرتبطة للكوروتين وتنفيذه.

go
func deferreturn() {
  gp := getg()
  for {
    d := gp._defer
    sp := getcallersp()
    if d.sp != sp {
      return
    }
    fn := d.fn
    d.fn = nil
    gp._defer = d.link
    freedefer(d)
    fn()
  }
}

أولًا ستُحصل على إطار المكدس للدالة الحالية عبر getcallersp() وتقارنه مع sp في بنية defer للحكم على ما إذا كان defer ينتمي للدالة الحالية، ثم تُخرج بنية defer من رأس القائمة المرتبطة، وتستخدم gp._defer = d.link للإشارة لـ defer التالي، ثم عبر الدالة runtime.freedefer تُطلق بنية defer إلى التجمع، وأخيرًا تستدعي fn للتنفيذ، وتستمر هكذا حتى تنتهي من تنفيذ جميع defer الخاصة بالدالة الحالية.

الترميز المفتوح

استخدام defer ليس بلا تكلفة، رغم أنه يوفر لنا راحة نحوية، لكنه في النهاية ليس استدعاء دالة مباشر، بل يمر بسلسلة من العمليات، لذا يسبب استهلاكًا في الأداء، لذلك صمم فريق Go لاحقًا طريقة تحسين تسمى الترميز المفتوح، وهي طريقة لتحسين defer، واسمها الأصلي بالإنجليزية open-coded، وتُرجمت محليًا للترميز المفتوح، وكلمة open هنا تعني التوسيع، أي توسيع كود دالة defer في كود الدالة الحالية، مثلما يحدث مع الدوال المضمنة. طريقة التحسين هذه لها الشروط التالية:

  1. عدد defer في الدالة لا يتجاوز 8
  2. حاصل ضرب عدد defer وعدد return لا يتجاوز 15
  3. defer لا يظهر في حلقة
  4. لم يُعطَّل تحسين المترجم
  5. لم يُستدعَ os.Exit() يدويًا
  6. لا حاجة لنسخ المعاملات من الكومة

يمكن تتبع منطق الحكم هذا في الدالة cmd/compile/ssagen.buildssa:

go
s.hasOpenDefers = base.Flag.N == 0 && s.hasdefer && !s.curfn.OpenCodedDeferDisallowed()
if s.hasOpenDefers && len(s.curfn.Exit) > 0 {
    s.hasOpenDefers = false
}
if s.hasOpenDefers {
    for _, f := range s.curfn.Type().Results().FieldSlice() {
        if !f.Nname.(*ir.Name).OnStack() {
            s.hasOpenDefers = false
            break
        }
    }
}
if s.hasOpenDefers && s.curfn.NumReturns*s.curfn.NumDefers > 15 {
    s.hasOpenDefers = false
}

ثم سينشئ Go في الدالة الحالية متغير صحيح 8-بت deferBits ليكون bitmap لتمييز defer، كل بت يمثل واحدًا، والعدد الصحيح 8-بت uint8 يمثل 8 كحد أقصى، إذا كان البت المقابل يساوي 1، فإن defer المُحسَّن بالترميز المفتوح سيُنفَّذ عندما توشك الدالة على الإرجاع.

Golang تم تحريره بواسطة www.golangdev.cn