nevalang / neva

🌊 Dataflow programming language with static types and implicit parallelism. Compiles to native code and Go
https://nevalang.org
MIT License
91 stars 7 forks source link

Is it possible to leave some outports unread? (Deadlocks, void sugar, err handling) #371

Closed emil14 closed 11 months ago

emil14 commented 11 months ago

The text is in Russian because I just wanna leave it here so it won't lost. It's easy to translate it to English if needed.

I was adding desugarer component to the compiler so user can use not all node's outports. Turns out this lead to deadlocks

emil14 commented 11 months ago

Кейс с ошибками

Message sent: main/in.enter[0] -> map[main/read1/in.sig[0]:{}] empty
Message pending: main/in.enter[0] -> main/read1/in.sig[0] empty
Message received: main/in.enter[0] -> main/read1/in.sig[0] empty
1
Message sent: main/read1/out.v[0] -> map[main/parse1/in.v[0]:{}] 1

Message pending: main/read1/out.v[0] -> main/parse1/in.v[0] 1

Message received: main/read1/out.v[0] -> main/parse1/in.v[0] 1

Message sent: main/parse1/out.err[0] -> map[main/void_main_005_add_two_numbers_1/in.v[0]:{}] strconv.Atoi: parsing "1\n": invalid syntax
Message pending: main/parse1/out.err[0] -> main/void_main_005_add_two_numbers_1/in.v[0] strconv.Atoi: parsing "1\n": invalid syntax
Message received: main/parse1/out.err[0] -> main/void_main_005_add_two_numbers_1/in.v[0] strconv.Atoi: parsing "1\n": invalid syntax
fatal error: all goroutines are asleep - deadlock!

Void и сахар на ним (а как следствие и весь desugarer) были добавлены чтобы избежать дедлоков, но этого не удалось.

Пример: parse.out.err шлет ошибку, наш код ее не обрабатывает, desugarer расползнает это как сахар над void'ом и добавляет неявный "вызов" войда с передачей ему непрочитанной ошибки. В результате войд вычитывает ошибку но... дальше ничего не происходит! Все горутины зависают в ожидании и происходит дедлок.

Что должно было произойти - программа должна каким-то образом была понять, что надо либо приостановить выполнение (кто-то должен послать сигнал в exit порт) либо начать все заново (сеть надо зациклить). Иными словами, ОШИБКА ДОЛЖНА БЫТЬ ОБРАБОТАНА

Мы же, добавив сахар с войдом над unused outports оставили дыру, позволяя не обрабатывать ошибки, что привело к взаимным блокировкам.

Получается что такого сахара быть не должно и юзер должен всегда использовать все порты всех узлов, как на вход, так и на выход. Что, однако, нам делать, когда нам не нужны все порты? Что насчет ситуации когда условный узел A имеет выходные порты x и y и нам попросту не нужен x, а нужен только y?

Быть может истина в том, что Void должен сигнализировать, что проглотил что-то. То есть, сам иметь outports?

Но тогда, будто, пропадает его смысл. Либо встает вопрос, куда девать этот его сигнал - отправить в такой же Void и породить бесконечную рекурсию либо же написать логику поверх этого, что будет по сути обработкой ошибки. Первое отметем за бессмысленностью а второе... Тогда зачем нам вообще все это надо? Пускай юзер ВСЕГДА использует ВСЕ

И снова мы возвращаемся к проблеме A.out.(x|y). Не будем же мы печатать или еще что-то делать с X когда он, ну не нужен и все.

Таким образом вопрос в том, как отделить одно от другого. Есть соблазн в виде такого решения: выходные порты можно не использовать (при условии, что используется хоть один) только если это не err (или, что, возможно, было бы правильнее, порт с типом Error).

Здесь есть риск недооценить глубину проблемы. Есть подозрение, будто дело не просто в том, что это ошибка а в том, что поток выполнения ни к чему не привел. Компоненты сделали свою работу и зависли. Программа не завершилась и не началась заново, она попала в тупик. Весь код оказался выполнен, но программа не завершена.

Может ли такая ситуация произойти в иных случаях, кроме как с обработкой ошибок?

Можем ли мы как-то неявно завершать программу, если сталкиваемся с дедлоком? Безопасно ли это?

emil14 commented 11 months ago

Более общее наблюдение

всякий раз когда из N портов мы задействуем только M, где M < N, при условии, что условие подачи сигнала разное, мы рискуем получить взаимную блокировку

иными словами, если порты A, B и С шлют данные не одновременно, а в зависимости от взаимоисключающих условий, так, что данные идут ЛИБО из A, ЛИБО из B, ЛИБО из C, то нам нужно подключиться ко всем трем, причем, таким образом чтобы поток выполнения не застопорился

что значит "застопорился"? это и есть дедлок или взаимная блокировка все компоненты (узлы) выполнили свою работу, но программа не завершилась

соответственно, надо чтобы ЛИБО был послан сигнал exit, ЛИБО продолжалось выполнение


вернемся к нашему примеру с A, B и C если мы (корректно (то есть так чтобы программа не застряла в блоке)) завязываемся только на A и игнорируем B и С (предположим, их вычитывает Void), то мы получаем блокировку всегда, когда A пуст, то есть когда не пусты B или C


почему такой проблемы нет в Go? можно представить что выходные порты компонента это множество возвращаемых значений функции

тогда Go позволяет спокойно проигнорировать (явно, однако, это похоже на Void) одно или больше (или даже все) значения

дело в том, что Go это язык, где мы управляем потоком выполнения

_, _, _ := foo()
bar() // дойдем до сюда без всяких блокировок

мы спокойно доходим до bar() потому что поток выполнения продолжается независимо от того, используем ли мы данные из foo или нет. поток выполнения всегда движется вперед подобно вычислителю что выполняет построчно инструкции. блокировки просто не может быть (мы сейчас не рассматриваем конкурентность в го)

в FBP же все конкуретно и неправильно написанная программа (некорректно "зароученные" данные) застревает в дедлоке

emil14 commented 11 months ago

I'm gonna close this one for now. This is the decision that I made:

All outports must be handled. This is the only way we have at least some compile-time guarantee that there will be no deadlocks. Even then deadlocks are possible with array-ports (e.g. some outport is used but the number of slots used is wrong).

What about desugarer?

Maybe we'll introduce it later. For now it's just not enough information about how one should design component API. The question is - do we create 2 outports for 1 flow or we use record/array/some kind of product-type and read specific field after?

Example: component X can fire foo and bar outports at the same time OR baz (and NOT foo/bar). Is this correct API? If so we must handle baz and EITHER foo OR bar. This is example of 2 flows that shows that each flow must be handled. Otherwise we face deadlock. This is the essence.

There's temptation to enforce this rule for err ports of each port with specific "Error" type. The thing is - there's no guarantee that error-handling is the only "branching" in the program's flow. Programmer is the network designer so for Neva compiler this information is unreachable.

emil14 commented 11 months ago

Just for the record

package desugarer

import (
    "fmt"
    "slices"

    "github.com/nevalang/neva/internal/compiler/src"
    "golang.org/x/exp/maps"
)

type Desugarer struct{}

func (d Desugarer) Desugar(prog src.Program) (src.Program, error) {
    result := make(src.Program, len(prog))

    for pkgName := range prog {
        desugaredPkg, err := d.desugarPkg(pkgName, prog)
        if err != nil {
            return src.Program{}, nil
        }
        result[pkgName] = desugaredPkg
    }

    return result, nil
}

func (d Desugarer) desugarPkg(pkgName string, prog src.Program) (src.Package, error) {
    pkg := prog[pkgName]
    result := make(src.Package, len(pkg))

    for fileName, file := range pkg {
        result[fileName] = src.File{
            Imports:  file.Imports,
            Entities: make(map[string]src.Entity, len(file.Entities)),
        }

        for entityName, entity := range file.Entities {
            scope := src.Scope{
                Loc: src.ScopeLocation{
                    PkgName:  pkgName,
                    FileName: fileName,
                },
                Prog: prog,
            }

            desugaredEntity, err := d.desugarEntity(entity, scope)
            if err != nil {
                return src.Package{}, fmt.Errorf("desugar entity: %w", err)
            }

            result[fileName].Entities[entityName] = desugaredEntity
        }
    }

    return result, nil
}

func (d Desugarer) desugarEntity(entity src.Entity, scope src.Scope) (src.Entity, error) {
    if entity.Kind != src.ComponentEntity {
        return entity, nil
    }

    desugaredComponent, err := d.desugarComponent(entity.Component, scope)
    if err != nil {
        return src.Entity{}, fmt.Errorf("desugar component: %w", err)
    }

    return src.Entity{
        Exported:  entity.Exported,
        Kind:      src.ComponentEntity,
        Component: desugaredComponent,
    }, nil
}

func (d Desugarer) desugarComponent(comp src.Component, scope src.Scope) (src.Component, error) { //nolint:funlen
    // node -> outports (we don't care about indexes)
    outportsUsedByNet := make(map[string]map[string]struct{}, len(comp.Nodes))
    for _, conn := range comp.Net {
        if conn.SenderSide.PortAddr == nil {
            continue
        }

        nodeName := conn.SenderSide.PortAddr.Node
        portName := conn.SenderSide.PortAddr.Port

        if _, ok := outportsUsedByNet[nodeName]; !ok {
            outportsUsedByNet[nodeName] = map[string]struct{}{}
        }

        outportsUsedByNet[nodeName][portName] = struct{}{}
    }

    desugaredNetwork := slices.Clone(comp.Net)
    voidNodeName := fmt.Sprintf("void_%s_%s", scope.Loc.PkgName, scope.Loc.FileName)

    for nodeName, node := range comp.Nodes {
        nodeEntity, _, err := scope.Entity(node.EntityRef)
        if err != nil {
            return src.Component{}, fmt.Errorf("scope entity: %w", err)
        }

        var io src.IO
        switch nodeEntity.Kind {
        case src.ComponentEntity:
            io = nodeEntity.Component.IO
        case src.InterfaceEntity:
            io = nodeEntity.Interface.IO
        }

        for outportName := range io.Out {
            nodeUsage, ok := outportsUsedByNet[nodeName]
            if !ok {
                continue // it's not ok that some node isn't used at all but let's analyzer handle that
            }

            if _, ok := nodeUsage[outportName]; !ok {
                desugaredNetwork = append(desugaredNetwork, src.Connection{
                    SenderSide: src.SenderConnectionSide{
                        PortAddr: &src.PortAddr{
                            Node: nodeName,
                            Port: outportName,
                            // FIXME this could be problem for array-ports that have many unused slots
                            Idx: 0, // IDEA do not allow to omit array-ports usage
                        },
                    },
                    ReceiverSides: []src.ReceiverConnectionSide{
                        {
                            PortAddr: src.PortAddr{
                                Node: voidNodeName,
                                Port: "v",
                            },
                        },
                    },
                })
            }
        }
    }

    desugaredNodes := make(map[string]src.Node, len(comp.Nodes))
    maps.Copy(desugaredNodes, comp.Nodes)

    if len(desugaredNetwork) > len(comp.Net) { // new connections were added while desugaring
        desugaredNodes[voidNodeName] = src.Node{
            EntityRef: src.EntityRef{
                Pkg:  "", // Void is builtin
                Name: "Void",
            },
        }
    }

    return src.Component{
        Interface: comp.Interface,
        Nodes:     desugaredNodes,
        Net:       desugaredNetwork,
    }, nil
}